Compare commits

..

39 Commits

Author SHA1 Message Date
6260b5a488 dataforth(datasheet): converge 8B/5B/SCM onto the DSCA template machinery (gated; WIP)
Generalized the shared renderer (datasheet-exact.js) so the per-model template path is
no longer DSCA-only — 8B/SCM5B now route through the same templates/slotMap/fround path
as DSCA. SCM7B stays on its separate parse7BRawData/legacy path (excluded). Changes
(all behind the per-model `validated` gate; non-templated families byte-unchanged, and
DSCA verified non-regressed at 54/56 + staged-92 still render):
- Load 8b5bscm-templates.json (MINED_8B5BSCM); for family 8B/SCM5B, a mined template
  takes the template path (else falls through to the legacy DATA_LINES path).
- Final-Test header is now the verbatim captured `ftHeader` (8B/5B use two schemes —
  legacy "Measured Value" vs DSCA "Measured Value*"); enrich-8b5bscm.js captured it for
  all 136 from the Hoffman originals.
- Accuracy data formatter chosen by input type detected from accHeader: frequency(Hz)/
  AC(AAC/VAC) -> formatAccuracyLineDSCA3345 (now takes inputType, not model prefix);
  standard voltage/current/temp -> legacy formatAccuracyLine.
- parseRawData step-skip generalized to any templated model (skipStepIfStatus).
- Footer 240VAC/Hi-Pot family blocks skipped for templated models (rows carry them).
- Templated SCM5B prepends "SCM" to the printed model name.
- slotmap-from-hoffman.js parameterized (DSCA_TPL) — derived 80 slotMaps for 8b5bscm.

Validation vs live Hoffman originals (validate-mined.js): 37/136 pass and are marked
validated -> 13 real 8B/SCM5B render live via the template path; 24 SCM7B already match
via the unchanged legacy path. Everything else stays null (gated, safe) — nothing
published.

Remaining long tail (well-characterized, next session): ~66 render-null need slotMaps
the Hoffman-oracle derivation couldn't get (vintage / parse); ~33 multi-nuance fails:
Final-Test sign handling on some 8B/5B measured values, accuracy-precision fround in the
legacy std formatter, "Packing Check List" vs "Check List" variants, and other per-model
footer/format quirks. Each failing model stacks several, so single fixes don't flip them.

Tools: enrich-8b5bscm.js, validate-mined.js (generalized validator), slotmap-from-hoffman.js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:21:24 -07:00
9c0481434e sync: auto-sync from AD2 at 2026-06-18 14:07:45
Author: Mike Swanson
Machine: AD2
Timestamp: 2026-06-18 14:07:45
2026-06-18 16:21:24 -07:00
35d6459adf dataforth(datasheet): publish DSCA33/45 gap — 1,452 new certs created, 0 overwrites
Probed each of the 1,578 unuploaded PASS serials across the 54 validated DSCA33/45
models against the Hoffman API (stale inventory -> can't trust api_uploaded_at as
"absent"). 1,452 were absent (404), 126 already live. Pushed ONLY the absent set:
created=1452 updated=0 unchanged=0 errors=0 — zero overwrites of pristine originals,
the handoff's hard requirement.

Tools: publish-dsca3345-gap.js (absent-only Created publish), validate-dsca3345.js.
Net: DSCA33/45 effort complete — 54/56 models live + validated; 2 rounding-boundary
holdouts and 2 no-original models (24 units) remain null.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:21:23 -07:00
e329d6f0f6 dataforth(datasheet): DSCA33/45 accuracy-data reverse-engineering — 54/56 validated
Cracked the DSCA33/45 accuracy-block numeric formatting against the Hoffman originals
(formatAccuracyLineDSCA3345):
- mA-output models store calc (and, for DSCA45, meas) in AMPS -> x1000 to display mA;
  DSCA33 stores meas already in display unit (NOT scaled), DSCA45 scales both.
- DSCA33 (AC-RMS): stim/calc/meas UNSIGNED, error signed; stim is AC input to 3 dp.
- DSCA45 (frequency): stim is an UNSIGNED integer Hz; calc/meas/error SIGNED.
- Math.fround on accuracy values (QB single-precision rounding), matching the Final-Test fix.
Final-Test fixes too: leading-zero drop only when the value overflows QB's 6-char field
("-0.0005"->"-.0005", but "-0.750" keeps its zero); spec-less section sub-heads
(Zero-Crossing Input / TTL Input) render with NO status (only Withstand/Hi-Pot get PASS);
DSCA33 prints a "Check List" header after the underline.

slotmap-from-hoffman.js (new): derive slotMaps for the models the staged multi-unit
derivation couldn't (vintage-heavy) by matching the Hoffman _srcSerial original's
Final-Test measured values (at display precision) to the DB STATUS entries. Recovered
all 13 remaining DSCA33 models.

Validation (validate-dsca3345.js, content-normalized byte-compare vs live Hoffman
originals): 54 of 56 models PASS and are marked validated:true (the render gate).
2 holdouts (DSCA33-04A, DSCA33-1891) each have ONE accuracy cert at a rounding boundary
where fround rounds opposite to the original; left UNvalidated -> still render null
(safe). DSCA33-1948 + DSCA45-1746 (24 units) have no Hoffman original.

Gate now OPEN for the 54 validated models (render live); 2 holdouts + the no-template
pair stay null. Publishing the api_uploaded_at IS NULL gap next (never re-pushes the
~7,157 pristine originals).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:21:23 -07:00
67220f9349 dataforth(datasheet): wire DSCA33/45 Hoffman-mined templates (gated; accuracy-data WIP)
Per the 5070 handoff (DSCA33-45-HOFFMAN-RECOVERY): the lost DSCA33/45 specs are
recoverable from Hoffman, not John. Wired the mined dsca33-45-templates.json (56
models) into the renderer:

- datasheet-exact.js: load DSCA3345_TEMPLATES; for family DSCA, the Hoffman-mined
  template takes PRECEDENCE over the stale staged-extraction entry (which shadowed 25
  models with accOut "?"/no accHeader). Emit the verbatim 2-line accHeader for these
  families (Vin (mVAC)/Iin (AAC)/Frequency (Hz), Output (VDC)/(mADC)). Per-model
  `validated` GATE: a DSCA33/45 model renders only after byte-matching its Hoffman
  original; until then it returns null (skipped) so an unverified render can never
  overwrite a pristine live original. DSCA_VALIDATE_MODE env opens the gate for the
  validation harness only. Exposed rendersWithoutSpecs().
- render-datasheet.js: allow a null-specs render for DSCA33/45 (their spec files were
  lost; template-driven) instead of bailing on missing specs.
- derive-dsca-slotmaps.js: DSCA_TPL env to target the 3345 templates; derived 43 slot
  maps into them (22 models need none, 8 DSCA33 still below threshold).
- validate-dsca3345.js (new): renders each model's _srcSerial, fetches the live
  Hoffman original (GET TestReportDataFiles/{serial}, deployed uploader token — no
  vault needed), content-normalized compare; --apply marks validated.

STATUS: gate is CLOSED — 0 models validated, all DSCA33/45 still render null, nothing
published, no risk. Final-Test block + accuracy headers now byte-match the Hoffman
originals for all 56 models; the remaining blocker is accuracy-DATA numeric quirks that
must match to pass the gate:
  - DSCA33 calc column stored in A but displayed in mADC (x1000); measured stored in
    mA (not scaled) — an original-software unit quirk.
  - sign conventions differ per layout (DSCA33 stim/calc/meas unsigned, error signed;
    DSCA45 stim unsigned, calc/meas/error signed).
  - DSCA45 frequency-input stim formatting.
These need per-layout reverse-engineering against the originals (the validation harness
is the oracle). 8 DSCA33 models (DSCA33-02/03/03A/04/04A/05/05A/1642) also lack a slot
map (below threshold). DSCA33-1948 + DSCA45-1746 (24 units) have no Hoffman original.

Cleanups: deleted superseded memory project_dsca33_45_spec_gap; struck the obsolete
"ask John" TODO 2 from the handoff note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:21:23 -07:00
6bac693044 sync: auto-sync from AD2 at 2026-06-18 08:05:50
Author: Mike Swanson
Machine: AD2
Timestamp: 2026-06-18 08:05:50
2026-06-18 16:21:23 -07:00
a6405175f5 sync: auto-sync from AD2 at 2026-06-18 08:02:06
Author: Mike Swanson
Machine: AD2
Timestamp: 2026-06-18 08:02:06
2026-06-18 16:21:23 -07:00
df112d37c7 memory: DSCA33/DSCA45 spec gap (missing main specs, not a bug)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:21:22 -07:00
a892c871d7 sync: auto-sync from AD2 at 2026-06-18 07:52:33
Author: Mike Swanson
Machine: AD2
Timestamp: 2026-06-18 07:52:33
2026-06-18 16:21:22 -07:00
3606630cbf dataforth(datasheet): Fix 2 — per-model slot maps resolve ambiguous DSCA layouts
Some DSCA subtypes' raw_data STATUS groups carry more (or fewer) value-bearing
entries than the template's spec-bearing rows (the test program measures slots the
printed sheet omits, e.g. DSCA49's 5mA load pair), so the in-order zip misaligned
values and those models were skipped by the count-guard.

New tool derive-dsca-slotmaps.js derives a per-model slotMap (absolute statusEntries
index per spec-bearing row) by greedily matching a staged original's printed values
to the DB raw_data STATUS entries (same fround formatting), then picking the
candidate map that validates against the most units. Models are grouped by identical
row-name signature and one map is derived per group from all sibling units — this
disambiguates duplicate values (e.g. a unit where 5mA != 50mA linearity forces the
correct slot; DSCA49-04 alone has only 2 staged units that can't, but its siblings'
25 units do). Stored as `slotMap` in dsca-templates.json.

Renderer: consults slotMap only when the sequential zip fails (value count !=
spec-row count), so the 88 already-clean models keep their path (no regression) and
ambiguous ones pull the right value via the map.

STAGE 3 re-validation: FINAL-TEST CLEAN 88 -> 92; 134 more certs now render
(null 450 -> 316); matches 2278 -> 2412. Same 6 retest-vintage dirty models, no
new mismatches. DSCA49 family + DSCA40-03 group now clean and validated.

Still blocked (separate gap, NOT layout ambiguity): DSCA45-* and most DSCA33-*
render null because they have NO spec-reader entries (render-datasheet bails before
rendering). Their slotMaps are derived and ready; they need spec coverage. One DSCA33
group (DSCA33-02/03/03A/04/05/1948) did not reach the slotMap validation threshold
(best 19/35 units) and stays skipped pending more/cleaner staged samples.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:21:22 -07:00
a90dad8709 sync: auto-sync from AD2 at 2026-06-18 07:33:56
Author: Mike Swanson
Machine: AD2
Timestamp: 2026-06-18 07:33:56
2026-06-18 16:21:22 -07:00
87c70614ec dataforth(datasheet): Fix 2 — data-driven DSCA load note (fixes DSCA39 footer artifact)
Root cause of the DSCA39 footer mismatch: the "Standard output load for test is
250 ohms." line is a footer note, not a parameter, but the STAGE 1 extractor
captured it as a (column-truncated) row "Standard output load for te". And the
renderer's OUTSIGTYPE==='CURRENT' emission was wrong on both ends — it printed the
note (after the underline, invisible to the validator gate) for many -C current
models whose staged originals never had it, and never placed it correctly for the
models that do.

Fix is data-driven, matching the rest of the template approach:
- derive-dsca-templates.js: detect the "Standard output load..." line, capture it
  as a per-model `loadNote` property, and exclude it from rows. Regenerated
  dsca-templates.json — surgically clean: only the 5 DSCA39 models changed (lost
  the truncated row, gained loadNote); all 121 others byte-identical.
- datasheet-exact.js: emit `dscaTpl.loadNote` (blank line + note) before the footer
  underline, only for models that have it; removed the OUTSIGTYPE-based emission.

STAGE 3 re-validation: FINAL-TEST CLEAN 85 -> 88, mismatches 9 -> 6, matches
2206 -> 2278. DSCA39-01/02/07 now fully clean; DSCA39-01 byte-content-verified.
No regression — the -C current models stayed clean and no longer carry the
spurious after-underline note.

The 6 remaining dirty models (DSCA38-05/-1793/-19C/-19E, DSCA39-05, DSCA39-1950)
are ALL retest data-vintage: the staged .TXT is an older test run than the DB
latest-wins record (Supply Current / Linearity differ by more than rounding).
Not render bugs — cannot be reconciled against an older sheet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:21:22 -07:00
2fbef29edd sync: auto-sync from AD2 at 2026-06-18 07:23:32
Author: Mike Swanson
Machine: AD2
Timestamp: 2026-06-18 07:23:32
2026-06-18 16:21:22 -07:00
859462aa91 dataforth(datasheet): Fix 2 — emulate QB single-precision rounding (26 -> 9 dirty)
formatMeasuredExact now applies Math.fround to the parsed value before toFixed.
The DOS QuickBASIC computed/stored these as single-precision floats, so the
last-digit rounding at the .5 boundary follows single, not double, precision.
Without it, double-precision toFixed flipped boundaries (9.9995 -> "9.999" vs
golden "10.000"; 46.85 -> "46.9" vs "46.8"; .45 -> "0.5" vs "0.4"; 3.3325 ->
"3.333" vs "3.332"). Verified each against the staged golden.

STAGE 3 re-validation: FINAL-TEST CLEAN models 68 -> 85 (+17), mismatches
26 -> 9, cert matches 2123 -> 2206. Zero regression — every remaining dirty
model was already dirty pre-fix; no previously-clean model flipped.

The 9 remaining are NOT rounding:
- 4 models (DSCA38-05/-1793/-19C/-19E): Supply Current retest data-vintage — the
  staged .TXT is an older test run than the DB latest-wins record; not a render
  bug, can't reconcile against an older sheet.
- 5 models (DSCA39-01/02/05/07/1950): STAGE 1 template artifact — the footer note
  "Standard output load for test is 250 ohms." was mis-captured as a truncated
  parameter row. A renderer-side fix was attempted but emitted the note for all
  current-output models (regressed 24 clean -C models), so it was reverted; needs
  a targeted STAGE 1 extractor fix instead.

Renamed dsca-clean68-models.json -> dsca-clean-models.json (now 85 models).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:21:22 -07:00
8a672e411a sync: auto-sync from AD2 at 2026-06-18 07:07:25
Author: Mike Swanson
Machine: AD2
Timestamp: 2026-06-18 07:07:25
2026-06-18 16:21:21 -07:00
48c5c7b31c dataforth(datasheet): Fix 2 — publish the 68 STAGE-3-clean DSCA models to Hoffman
Restarted testdatadb service (new template live), canary-pushed 1 cert (updated,
0 errors), then re-pushed all PASS certs for the 68 Final-Test-content-clean
models via uploadBySerialNumbers from a fresh node process.

Result over 30,423 PASS certs: updated=26022 unchanged=2738 created=0 errors=0
skipped=1663. The 26,022 updates replace the old defective DSCA renders on the
live site with the rebuilt, byte-content-validated ones. The 1,663 skips are the
count-guard correctly refusing any individual cert whose value count != its
template row count (ambiguous) — never publishing misaligned data.

Artifacts: push-clean68.js (driver), dsca-clean68-models.json (the published
set). NOT yet published: the 26 last-digit-diff models, the ~32 ambiguous/null
layout families, and the 231 untemplated models.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:21:21 -07:00
f3ce86c9f4 dataforth(datasheet): Fix 2 STAGE 3 — DSCA render validator + first full report
validate-dsca-stage3.js: read-only harness that, for every staged DSCA original
we have ground truth for (2806 across 126 models), looks up the DB record,
renders it through the live path, and content-compares. GATE = the FINAL TEST
RESULTS section (rule lines canonicalized, whitespace collapsed — so the deferred
column-spacing cosmetic doesn't register); accuracy-section diffs reported
separately as informational.

First run verdict (report attached):
- 68 models FINAL-TEST CONTENT-CLEAN (0 mismatch over compared certs).
- 2123/2316 certs match the Final-Test content exactly (91.7%).
- 26 models show measured-value last-digit diffs only — structure (names, specs,
  row alignment, statuses) is correct. Two root causes, neither structural:
    * rounding-mode: JS double toFixed vs QB single-precision half-up
      (e.g. raw 9.9995 code3 -> "9.999" here, "10.000" in golden). Fixable but
      float-precision-sensitive; risks regressing currently-clean values.
    * data vintage: staged .TXT is a different test run than the DB latest-wins
      record (Fix 3) — e.g. Supply Current 19.6 vs 20.3, 0.7 apart. Not a render
      bug; can't be reconciled against an older staged sheet.
- ~32 models render null (count-guard): DSCA33-*, DSCA45-*, DSCA49-* families
  whose raw_data carries load points the template omits -> need per-subtype slot
  mapping (the canonical-slot approach) before they can render.

Still NOT published: service not restarted, nothing re-pushed to Hoffman.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:21:21 -07:00
9d847a6e81 dataforth(datasheet): Fix 2 STAGE 2 — wire DSCA per-model templates into render
Deployed file is C:\Shares\testdatadb\templates\datasheet-exact.js; this
reconciles the repo copy + adds dsca-templates.json (STAGE 1 output).

What changed in generateExactDatasheet (DSCA family only; 5B/8B/7B/DSCT/SCMVAS
paths byte-unchanged):
- Load dsca-templates.json once at module top (126 per-model layouts).
- DSCA Final-Test now renders names + specs from the staged template rows, not
  the single hardcoded DATA_LINES['DSCA'] + buildTSpecs DSCA branch.
- Value-bearing raw_data STATUS groups map positionally onto the spec-bearing
  template rows; empty-spec rows (240VAC Withstand / Hi-Pot) render blank+PASS.
  Removed the duplicate hardcoded 240VAC/Hi-Pot footer for DSCA (now rows).
- ACCURACY header uses the template accOut ("Output (V)"/"Output (mA)") with '-'
  rule separators instead of "Vout (V)" + '='.
- Header/columns match the staged originals (Measured Value*, 25/15/19/6 rule).

Two real bugs fixed (both are the handoff's "lines drop" / wrong-value defect):
- formatMeasuredExact reads the value from index 4 so negative signs survive
  ("PASS-4.24..." -> "-4", not "4"); also decimal-code N -> toFixed(N) exactly
  (DSCA differs from 5B/8B where code 2 means 1 decimal).
- parseRawData no longer consumes the first DSCA STATUS group as a bare
  step-response line when that line is absent (dropped 3 rows on e.g. DSCA39-01).

Safety: when value count != spec-row count the positional zip is ambiguous
(subtype measures load points the template omits, e.g. DSCA49 5mA pair), so the
cert is SKIPPED (null) and left for STAGE 3 per-subtype mapping rather than
emitting misaligned data.

Validation: DSCA38-05 (SN 180224-1) Final-Test block byte-identical to its
staged original. 92/126 templated models render cleanly; 7 ambiguous + 27
no-spec skip. Remaining ACCURACY-block spacing diffs are the deferred cosmetic
gap. NOT YET LIVE: testdatadb service not restarted, nothing re-pushed to
Hoffman (STAGE 3 gate).

Coverage gap to resolve before publish: only 126/357 DSCA models in the DB have
a staged template (56,074 certs, 70.1%); 231 models / 23,866 certs have none and
now render null — needs a STAGE 1 extension (more staged originals).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:21:21 -07:00
c40867934c sync: auto-sync from AD2 at 2026-06-18 06:13:27
Author: Mike Swanson
Machine: AD2
Timestamp: 2026-06-18 06:13:27
2026-06-18 16:21:21 -07:00
8c41e0e934 sync: auto-sync from AD2 at 2026-06-17 16:21:47
Author: Mike Swanson
Machine: AD2
Timestamp: 2026-06-17 16:21:47
2026-06-18 16:21:20 -07:00
4fd833bdf3 dataforth(datasheet): save email draft to John summarizing all findings
Plain-language summary for John (EE, non-developer) covering all 4 problems
(RTD label, DSCA table, same-day run, missing units), framed as ACG-owned fixes.
Cross-links the four supporting technical docs. Draft - not yet sent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:21:20 -07:00
13e06cf2f0 dataforth(datasheet): correct Cause 2 of missing units - cryptolocker incident, not import timing
Per Mike: import runs every 15 min, so routine timing isn't the cause. The 379
absent units are confined to 2025-10..2026-01 (stop after Jan 2026) on TS-4L/4R/1R
- fingerprint of a one-time overwrite during the incident/recovery (fresh DOS logs
overwrote accumulated appended server-side logs for ~2 weeks). One-time, not
recurring; backfill from the surviving staged .TXT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:21:20 -07:00
3760f374a4 dataforth(datasheet): root-cause the 608 missing units (report for John)
608 staged datasheets absent from DB. Two causes: (1) 229 units with encoded/
non-standard serials the importer's leading-digit regex silently skips - data is
in the .DAT, recoverable; full blind spot is 840 serials / 9,510 records / 141
models dropped fleet-wide. (2) 379 units whose per-model .DAT was overwritten by a
later work order - recoverable only from the staged .TXT or a log backup. Adds
John-facing report, raw data, and the chase-missing-units.js tool.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:21:20 -07:00
1f08831035 dataforth(datasheet): same-day retest faithfulness — exposure sweep + fix proposal
Whole-source sweep (981,716 records / 406,549 serials): 6,515 same-day multi-run
events; DB holds a NON-latest run for 311 (the strictly-greater-date conflict rule
freezes on an arbitrary same-day run). Corrects the verdict doc to flag same-day
retests as a latest-wins faithfulness violation (not benign). Adds the proposed
>= -with-data-differs conflict-rule fix (diagnose-only) and the sweep tool.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:21:20 -07:00
fe559e61ba dataforth(datasheet): parsing-fidelity validation — all staged originals vs DB
Validated all 11,922 staged original .TXT datasheets against test_records.
0 genuine parse faults across 11,239 comparable records; mismatches all explained
(retests, reused serials, VAS format, legacy out-of-scope units). Adds the
validate-parsing.js tool, raw report, and verdict. Two follow-ups (NOT parse bugs):
608 staged units absent from DB (ingestion completeness), and same-day retests keep
the first run (ON CONFLICT strictly-greater-date).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:21:20 -07:00
dab20749cc dataforth(datasheet): PROPOSED Defect-A fix — render RTD input as Temp (C), not resistance
Repo copy only (review before deploying to C:\Shares\testdatadb). Folds RTD
(sensorNum 7) into the temperature path so the ACCURACY input column shows
'Temp. (C)' with signed temperature values, matching the original DOS-generated
datasheets and thermocouples (3-6). raw_data stimulus is already in deg C; no
conversion. getSensorNum and the i==13 ohm/ohm unit override are untouched.

Verified read-only against deployed env: 8B35 SN 179553-13 now shows Temp (C);
regression over 184 5B/8B renders -> 15 RTD changed (intended), 0 non-RTD changed.
Does NOT address Defect B (DSCA template). See DATASHEET-RTD-BUG-DIAGNOSIS-2026-06-17.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:21:20 -07:00
f59d1ee862 memory: AD2 sync.sh pushes main not ad2 (fork push gotcha)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:21:20 -07:00
16ab908efe sync: auto-sync from AD2 at 2026-06-17 13:56:18
Author: Mike Swanson
Machine: AD2
Timestamp: 2026-06-17 13:56:18
2026-06-18 16:21:20 -07:00
763da7a7bb memory: record AD2 Dataforth-fork structure + sync gotchas
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:21:20 -07:00
8c94ba1459 dataforth: preserve fork operational context as clients/dataforth/CLAUDE.dataforth.md
Relocated verbatim from .claude/CLAUDE.md so the ad2 fork stops editing the
shared fleet harness doc. After rebase onto main, .claude/CLAUDE.md = the lean
fleet version; Dataforth ops context lives here. Keeps future ad2 syncs clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:21:19 -07:00
d561e807e6 sync: auto-sync from AD2 at 2026-06-17 13:35:55
Author: Mike Swanson
Machine: AD2
Timestamp: 2026-06-17 13:35:55
2026-06-18 16:21:16 -07:00
sysadmin
86b60f44ae Add AD scripts and stage import instructions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-18 16:21:11 -07:00
sysadmin
46b71065f1 Session log 2026-04-03: WO import, 7B support, PG migration started
- 33K work orders imported, 2.27M records linked
- 7B exact-match formatter added (31 params, 120VAC, Packing Check List)
- TXT formatting refined to match QB TAB positions exactly
- PostgreSQL 18 installed on AD2, database created
- SQL Server Express uninstalled
- Full Dataforth audit document generated

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-18 16:21:11 -07:00
ce1ad1d5f2 fix(wiki-compile): release coord lock by ID, not resource path
coord.py 'lock release' takes the lock ID; the documented path form no-ops
and strands the lock until TTL. Capture the lock ID at claim (5.0), release
it in Phase 6. Recurring friction (errorlog 2x).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:31:38 -07:00
a1733b927b sync: auto-sync from HOWARD-HOME at 2026-06-18 15:31:12
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-06-18 15:31:12
2026-06-18 15:31:20 -07:00
7c038019d7 wiki: compile cascades-tucson (full) — network-logging plan + voice migration sources
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:28:47 -07:00
5b81a65c56 sync: auto-sync from HOWARD-HOME at 2026-06-18 15:22:15
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-06-18 15:22:15
2026-06-18 15:22:30 -07:00
b2a8d0c161 sync: auto-sync from GURU-5070 at 2026-06-18 14:35:22
Author: Mike Swanson
Machine: GURU-5070
Timestamp: 2026-06-18 14:35:22
2026-06-18 14:35:38 -07:00
03527db04f security.azcomputerguru.com: scaffold the client security-assessment intake app
New MSP tool — single-assessor consult intake. Stack: PHP + MySQL on the IX cPanel
host, gated by Cloudflare Access (only mike@azcomputerguru.com; app re-checks the
Cf-Access-Authenticated-User-Email header).

- app/questions.json — risk-ordered question framework (9 sections); each field tagged
  source=syncro/rmm/scan/ask so the consult asks only what a human knows and the
  post-meeting scan fills the technical reality.
- app/index.php — wizard UI: Syncro phone lookup -> prefill, section rail with live
  progress, importance-colored question cards, in-meeting 365/Google consent links,
  review + export.
- app/api.php — Syncro lookup-by-phone, save/load/list, consent-URL generation (reuses
  the read-only Security Investigator app bfbc12a4-...), HTML export.
- app/schema.sql, config.sample.php, DEPLOY.md, README.md.

Consent links let the client approve read-only 365/Google access during the consult so
the audit scan runs afterward. Read-only by design (reads Syncro, generates consent; no
tenant writes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:20:05 -07:00
18 changed files with 825 additions and 7532 deletions

View File

@@ -368,6 +368,8 @@ into a conflict, and no synthesis lands in the live article without a review.
**5.0 Claim a per-article coord lock** (via the `coord` skill):
`lock claim claudetools wiki/<type>/<slug> "wiki-compile <slug>" --ttl 1`.
- **Capture the returned lock ID** — the claim prints `[coord] lock id=<uuid> ...`. Save it
(`LOCK_ID=<uuid>`); the release in Phase 6 takes the **lock ID, not the resource path**.
- The TTL auto-evicts a dead session's lock (no permanent stranding).
- If the lock is **already held** → emit `[SKIP] wiki/<type>/<slug> is being compiled on
another machine; try again shortly` and exit cleanly.
@@ -409,7 +411,8 @@ git commit -m "wiki: compile ${SLUG} (${MODE})"
git fetch origin && git rebase origin/main # serialized, but rebase defensively
git push origin main
# Release the per-article lock and clear staging (ALWAYS — even on an earlier abort):
$PY "$CLAUDETOOLS_ROOT/.claude/skills/coord/scripts/coord.py" lock release claudetools "wiki/${TYPE}/${SLUG}" 2>/dev/null || true
# NOTE: `lock release` takes the LOCK ID (captured at claim in 5.0), NOT the resource path.
$PY "$CLAUDETOOLS_ROOT/.claude/skills/coord/scripts/coord.py" lock release "$LOCK_ID" 2>/dev/null || true
rm -f "$CLAUDETOOLS_ROOT/.claude/wiki_staging/${TYPE}-${SLUG}.md"
```

View File

@@ -3,6 +3,8 @@
- **Machine:** Howard-Home
- **Role:** tech
> Continued same session — see `## Update: 15:21 PT` at the end for the Cascades wiki full recompile and Syncro-note credential vaulting that followed the DESKTOP-TRCIEJA diagnosis.
## Session Summary
Diagnosed slow performance on Lupe Sanchez's workstation **DESKTOP-TRCIEJA** (Cascades of Tucson), reported as the machine being slow when opening Excel files on the desktop. Located the agent via `rmm-search` (GuruRMM agent `c9bf1a2d-bfdc-401e-9cc8-f9e90bb19587`, online, Windows 11 Pro build 22000) and ran a full performance diagnostic over the GuruRMM agent in SYSTEM context.
@@ -77,3 +79,47 @@ None discovered or created this session. RMM auth via existing vault path `infra
- RMM API: `http://172.16.3.30:3001`; vault `infrastructure/gururmm-server.sops.yaml`.
- Diagnostic command IDs: `c31c6552-...` (failed combined), `acb9eba4-...` (sanity), `bae771da-ce6e-4b14-be38-b66c07fd96bf` (recon).
- Wiki: `wiki/clients/cascades-tucson.md` (line ~415: leftover Datto RMM/EDR cleanup item).
---
## Update: 15:21 PT — Cascades wiki full recompile + Syncro-note credential vaulting
### Session Summary (continuation)
After the DESKTOP-TRCIEJA diagnosis was saved and synced, ran `/wiki-compile client:cascades-tucson --full` (full recompile). Pulled live Syncro data (customer 20149445: 55.75 prepaid hrs, 0 open tickets, 29 assets, prepaid-block billing), discovered 55 client session logs, claimed the per-article coord lock, and delegated the synthesis to a Sonnet subagent. The subagent wrote the recompiled article to `.claude/wiki_staging/`; reviewed the staged diff (no raw secrets, no conflict markers, all sections intact, Patterns/History preserved — flagged diff lines were re-dated status lines, not deletions), applied staged->live (508->570 lines), updated the index row, committed/pushed, and released the lock.
While pulling Syncro data, found the Cascades customer-notes field is a large plaintext credential dump. Per Howard's instruction, vaulted the credentials that were NOT already vaulted (and did not overwrite existing entries, since the notes may be stale), and left the Syncro note unscrubbed. Compared each note credential against the existing 23 Cascades vault entries: created 4 new entries, skipped exact-match and stale-conflict creds. Howard confirmed the CSCNet WiFi password is `Ftfd85710#` (vault value), resolving a b/d discrepancy vs the Syncro note's `Ftfb85710#` typo; updated the wifi-cscnet entry's verification metadata.
### Key Decisions (continuation)
- **Full recompile via Sonnet subagent** (high-volume read across 55 logs + 508-line article) rather than inline — followed the wiki-compile skill's staged + serialized flow.
- **Vault only non-duplicate, non-conflicting credentials.** Where the Syncro note's value differed from an already-vaulted entry, treated the vault as authoritative (note is older) and did NOT overwrite — only flagged.
- **Did not scrub the Syncro note** (Howard's call).
- **CSCNet password confirmed `Ftfd85710#`** by Howard; Syncro note `Ftfb...` is a transcription typo.
### Configuration Changes (continuation)
- `wiki/clients/cascades-tucson.md`: full recompile, 508 -> 570 lines (commit c76bdc4).
- `wiki/index.md`: Cascades row refreshed (hours date 2026-06-18, added voice VLAN30/power-outage/DESKTOP-TRCIEJA highlights, date col -> 2026-06-18).
- Vault — 4 NEW entries created (commit 39d7da4): `clients/cascades-tucson/m365-default-user-password`, `.../godaddy`, `.../kpax-agent`, `.../alis-api-microsoft-basic`. Each tagged with `source: Syncro customer notes 20149445; may be stale`.
- Vault — `clients/cascades-tucson/wifi-cscnet` updated (commit 3db85ce): `password_last_verified=2026-06-18`, added `password_note` documenting the b/d typo and Howard's confirmation.
### Credentials & Secrets (continuation)
Vaulted from Syncro customer notes (customer 20149445). NOT reproduced here — see vault paths above. Mapping:
- **NEW (vaulted):** M365 default new-user password; GoDaddy (user `cascadestucson`, customer # 191566536); KPAX print-monitoring PUK (Cartridge World, `cartridgeworld.kpax.cloud`); ALIS API basic-auth (`microsoft@cascadestucson`, used by Microsoft to call ALIS).
- **Skipped, exact match already vaulted:** Synology NAS (`synology-cascadesds`, 192.168.0.120 admin).
- **Skipped, vault has NEWER value (Syncro note stale, not overwritten):** `m365-sysadmin` (sysadmin@cascadestucson.com), `m365-admin` (admin@cascadestucson.com). Syncro note had old generic passwords; vault entries carry the post-2026-06-05 rotated passwords matching the live accounts.
- **Resolved discrepancy:** CSCNet WiFi = `Ftfd85710#` (vault, confirmed by Howard); Syncro note's `Ftfb85710#` is a typo.
### Pending / Incomplete Tasks (continuation)
- Order replacement workstation for Lupe Sanchez (carried from main diagnosis).
- Syncro note for Cascades (customer 20149445) still contains the full plaintext credential dump — left intentionally unscrubbed this session; scrub is a future decision.
- M365 admin passwords in the Syncro note are stale — informational only; no action.
### Reference Information (continuation)
- Wiki commit: c76bdc4 (cascades-tucson full recompile). Vault commits: 39d7da4 (4 new entries), 3db85ce (wifi-cscnet verify).
- Syncro customer: Cascades of Tucson, ID 20149445, 55.75 prepaid hrs, 0 open tickets, 29 assets.
- Coord lock id (released): d2d08409-fb95-48f3-b8b3-f47402c3accc on resource wiki/clients/cascades-tucson.

View File

@@ -17,6 +17,10 @@ Categories (the `[type]` tag): _(none)_ = skill/command execution failure ·
<!-- Append entries below this line -->
2026-06-18 | GURU-5070 | agy/search | gemini CLI threw ineligible/projectId setup error (throwIneligibleOrProjectIdError), empty response after 3 attempts [ctx: mode=search host=GURU-5070]
2026-06-18 | GURU-5070 | agy | gemini returned no response (empty after 3 attempts) [ctx: mode=search err= at process.processTicksAndRejections (node:internal/process/task_queues:104:]
2026-06-18 | Howard-Home | git/sync-temp-files | [friction] controller-query scratch (.sta.json/.dev.json/.q*) written to repo CWD got swept into the commit by sync.sh 'git add -A', then a stray locked .sta.json blocked the rebase. Fix: write API scratch OUTSIDE the repo (or use the already-ignored .tmp- prefix); gitignored the patterns [ctx: ref=howard-home /tmp friction family]
2026-06-18 | Howard-Home | rmm | [friction] agent returns exit -1 'Failed to execute command' on a ~7KB multi-line powershell body sent as one command; split into <2KB section scripts and each ran fine [ctx: host=DESKTOP-TRCIEJA agent=0.6.66]

File diff suppressed because one or more lines are too long

View File

@@ -30,6 +30,17 @@ try {
DSCA3345_TEMPLATES = {};
}
// 8B / SCM5B per-model templates recovered from the Hoffman API (same data gap as
// DSCA33/45). Same superset schema plus a verbatim `ftHeader` (8B/5B use two Final-Test
// header schemes: legacy "Measured Value" vs DSCA-style "Measured Value*"). Per-model
// `validated` gate. SCM7B is NOT templated here (separate parse path). Loaded once.
let MINED_8B5BSCM = {};
try {
MINED_8B5BSCM = require('../8b5bscm-templates.json');
} catch (e) {
MINED_8B5BSCM = {};
}
// -------------------------------------------------------------------------
// DATA LINES: parameter names and units per family
// -------------------------------------------------------------------------
@@ -180,7 +191,7 @@ function getSensorNum(sentype) {
// Parse raw_data from DB record
// -------------------------------------------------------------------------
function parseRawData(rawData, family) {
function parseRawData(rawData, family, skipStepIfStatus) {
if (!rawData) return null;
const lines = rawData.split('\n').map(l => l.trim()).filter(l => l.length > 0);
@@ -213,13 +224,15 @@ function parseRawData(rawData, family) {
}
// Next line: step response / placeholders.
// SCM5B/8B: "0","0",value DSCT: just value. Many DSCA models OMIT this bare
// line and go straight to the STATUS groups; consuming a STATUS group here
// drops a Final-Test row (the "lines drop" defect). For DSCA, skip consuming
// when the line is actually a STATUS group (starts with PASS/FAIL).
// SCM5B/8B: "0","0",value DSCT: just value. Many DSCA models — and the templated
// 8B/5B subtypes (8B45/8B49/5B39/SCM5B33...) — OMIT this bare line and go straight to
// the STATUS groups; consuming a STATUS group here drops a Final-Test row (the "lines
// drop" defect). Skip consuming when the line is actually a STATUS group (PASS/FAIL)
// for DSCA or any templated model (skipStepIfStatus); non-templated families keep the
// original behavior byte-for-byte.
if (lineIdx < lines.length) {
const looksLikeStatus = /^"?(PASS|FAIL)/i.test(lines[lineIdx].trim());
if (!(family === 'DSCA' && looksLikeStatus)) {
if (!((family === 'DSCA' || skipStepIfStatus) && looksLikeStatus)) {
const parts = parseCSVLine(lines[lineIdx++]);
const lastVal = parts[parts.length - 1];
result.stepResponse = parseFloat(lastVal) || 0;
@@ -532,9 +545,9 @@ function formatAccuracyLine(point, sensorNum, maxIn) {
* to 3 decimals.
* - DSCA45 (frequency input) prints stim as an UNSIGNED integer Hz; calc/meas/error SIGNED.
*/
function formatAccuracyLineDSCA3345(point, model, accOut) {
function formatAccuracyLineDSCA3345(point, inputType, accOut) {
const scale = /mA/.test(accOut || '') ? 1000 : 1;
const isDSCA45 = /^DSCA45/i.test((model || '').trim());
const isDSCA45 = inputType === 'freq'; // frequency-input (Hz stim); else AC-RMS
// values were computed in QB single precision; recover the single before formatting
// so last-digit rounding at the .5 boundary matches the original (Math.fround).
const num = (val, decimals, signed) => ((signed && val >= 0) ? '+' : '') + Math.fround(val).toFixed(decimals);
@@ -598,24 +611,29 @@ function generateExactDatasheet(record, specs) {
// specs + accuracy label). Source is the staged-original set (dsca-templates) or
// the Hoffman-mined set (dsca33-45-templates) for the families whose specs were
// lost. No template -> do not guess; skip this cert.
const dscaKey = (record.model_number || '').trim();
// Hoffman-mined templates take PRECEDENCE: DSCA33/45 were also captured by the
// STAGE 1 staged extractor (sometimes with accOut "?" and no accHeader), and that
// stale entry must not shadow the authoritative Hoffman-mined one.
const dscaTpl = (family === 'DSCA')
? (DSCA3345_TEMPLATES[dscaKey] || DSCA_TEMPLATES[dscaKey] || null)
: null;
if (family === 'DSCA' && !dscaTpl) return null;
// Hoffman-mined DSCA33/45 render only once the model is byte-validated against its
// Hoffman original — otherwise stay null so an unverified render can't overwrite a
// live original. The validation harness sets DSCA_VALIDATE_MODE to render
// unvalidated models for the byte-compare; the live service never sets it.
if (family === 'DSCA' && DSCA3345_TEMPLATES[dscaKey] && !DSCA3345_TEMPLATES[dscaKey].validated
&& !process.env.DSCA_VALIDATE_MODE) return null;
const modelKey = (record.model_number || '').trim();
// Per-model template driving the Final-Test (+ accuracy) layout. DSCA uses the staged
// set or the Hoffman-mined DSCA33/45 set (mined takes PRECEDENCE over a stale staged
// entry); 8B/SCM5B use the Hoffman-mined 8b5bscm set. SCM7B is excluded (separate
// parse path). `minedTpl` = the Hoffman-mined entry (accHeader/ftHeader + a per-model
// `validated` gate).
let tpl = null, minedTpl = null;
if (family === 'DSCA') {
minedTpl = DSCA3345_TEMPLATES[modelKey] || null;
tpl = minedTpl || DSCA_TEMPLATES[modelKey] || null;
if (!tpl) return null; // no DSCA template -> do not guess the layout
} else if (family === '8B' || family === 'SCM5B') {
minedTpl = MINED_8B5BSCM[modelKey] || null;
tpl = minedTpl; // null -> falls through to the legacy DATA_LINES path
}
// Hoffman-mined models render only once byte-validated against their original (else
// null) so an unverified render can't overwrite a live original. The validation
// harness sets DSCA_VALIDATE_MODE to open the gate for its compare.
if (minedTpl && minedTpl.accHeader && !minedTpl.validated && !process.env.DSCA_VALIDATE_MODE) return null;
const parsed = (family === 'SCM7B')
? parse7BRawData(record.raw_data)
: parseRawData(record.raw_data, family);
: parseRawData(record.raw_data, family, !!tpl);
if (!parsed) return null;
if (family !== 'SCM7B' && parsed.accuracy.length < 5) return null;
@@ -634,10 +652,14 @@ function generateExactDatasheet(record, specs) {
: record.test_date || '';
let modelName = specs ? specs.MODNAME : record.model_number;
// 7B header prepends "SCM" to the model name
// 7B header prepends "SCM" to the model name; templated SCM5B do the same (the spec
// MODNAME is stored as "5B49-03" but the original prints "SCM5B49-03").
if (family === 'SCM7B' && !modelName.toUpperCase().startsWith('SCM')) {
modelName = 'SCM' + modelName;
}
if (tpl && family === 'SCM5B' && !modelName.toUpperCase().startsWith('SCM')) {
modelName = 'SCM' + modelName;
}
const lines = [];
const TAB5 = ' '; // 4 spaces = TAB(5) in QB (0-indexed)
@@ -667,12 +689,12 @@ function generateExactDatasheet(record, specs) {
} else {
lines.push(' ACCURACY TEST');
lines.push('');
if (dscaTpl && Array.isArray(dscaTpl.accHeader) && dscaTpl.accHeader.length >= 2) {
if (tpl && Array.isArray(tpl.accHeader) && tpl.accHeader.length >= 2) {
// DSCA33/45 (Hoffman-mined): the accuracy header carries model-specific tokens
// the sensor-type logic can't synthesize (Vin (mVAC), Iin (AAC), Frequency (Hz),
// Output (VDC)/(mADC)). Emit the verbatim 2-line header from the original.
lines.push(dscaTpl.accHeader[0]);
lines.push(dscaTpl.accHeader[1]);
lines.push(tpl.accHeader[0]);
lines.push(tpl.accHeader[1]);
lines.push(TAB5 + '-'.repeat(10) + ' ' + '-'.repeat(11) + ' ' + '-'.repeat(11) + ' ' + '-'.repeat(10) + ' ' + '-'.repeat(8));
} else {
lines.push(' Calculated Measured');
@@ -688,15 +710,22 @@ function generateExactDatasheet(record, specs) {
}
// DSCA labels its accuracy output column "Output (V)"/"Output (mA)" (from the
// template) with '-' rule separators; 5B/8B/etc. use "Vout (V)" with '='.
const accOut = (family === 'DSCA' && dscaTpl) ? dscaTpl.accOut : 'Vout (V)';
const accOut = (family === 'DSCA' && tpl) ? tpl.accOut : 'Vout (V)';
const accSep = (family === 'DSCA') ? '-' : '=';
lines.push(' ' + inputHeader + ' ' + accOut + ' ' + accOut + '* Error (%) Status');
lines.push(TAB5 + accSep.repeat(10) + ' ' + accSep.repeat(10) + ' ' + accSep.repeat(10) + ' ' + accSep.repeat(9) + ' ' + accSep.repeat(8));
}
// For a mined template, the accuracy DATA columns follow the input type: frequency
// (Hz) and AC (AAC/VAC) use the DSCA33/45 conventions (unsigned/integer stim, mA
// scaling); standard voltage/current/temperature inputs use the legacy signed
// formatter (sensor-type + maxIn from specs, which 8B/5B have).
const accHdrStr = (tpl && Array.isArray(tpl.accHeader)) ? tpl.accHeader.join(' ') : '';
const accInput = /\(Hz\)/.test(accHdrStr) ? 'freq'
: (/AAC|VAC|mVAC/.test(accHdrStr) ? 'ac' : 'std');
for (const point of parsed.accuracy) {
if (dscaTpl && Array.isArray(dscaTpl.accHeader)) {
lines.push(formatAccuracyLineDSCA3345(point, record.model_number, dscaTpl.accOut));
if (tpl && Array.isArray(tpl.accHeader) && (accInput === 'freq' || accInput === 'ac')) {
lines.push(formatAccuracyLineDSCA3345(point, accInput, tpl.accOut));
continue;
}
lines.push(formatAccuracyLine(point, sensorNum, maxIn));
@@ -708,12 +737,12 @@ function generateExactDatasheet(record, specs) {
// QB column positions (1-indexed): TAB(31), TAB(47), TAB(60-speclen), TAB(61), TAB(71)
lines.push(' FINAL TEST RESULTS');
lines.push('');
if (family === 'DSCA') {
// DSCA Final-Test renders from the per-model staged template: the rows give
// the parameter names + specs (and accuracy label) directly; the value-bearing
// raw_data STATUS groups map positionally onto the spec-bearing rows. Rows with
// an empty spec (240VAC Withstand / Hi-Pot) carry no measured value and render
// as PASS. Header/column scheme matches the staged originals.
if (tpl) {
// Template-driven Final-Test (DSCA, and Hoffman-mined 8B/SCM5B): the rows give
// the parameter names + specs directly; the value-bearing raw_data STATUS groups
// map positionally onto the spec-bearing rows (via slotMap when counts differ).
// Spec-less pass/fail rows (240VAC Withstand / Hi-Pot) render PASS; spec-less
// section sub-heads (Zero-Crossing/TTL) render with no status.
// Value-bearing measurements in source order (drop "PASS"/"" padding entries).
const measurements = [];
@@ -721,37 +750,42 @@ function generateExactDatasheet(record, specs) {
const m = formatMeasuredExact(s);
if (m) measurements.push(m);
}
const specRowCount = dscaTpl.rows.filter(r => (r.spec || '').trim()).length;
const specRowCount = tpl.rows.filter(r => (r.spec || '').trim()).length;
// The simple positional zip is sound only when there is exactly one measured
// value per spec-bearing row. When counts differ, this subtype measures slots
// the template omits (e.g. an extra load pair); use the per-model slotMap
// (absolute statusEntries index per spec-bearing row, derived from the staged
// originals) to pull the right value. With no usable slotMap, skip rather than
// misalign ("do not guess").
// value per spec-bearing row. When counts differ, the subtype measures slots the
// template omits; use the per-model slotMap (absolute statusEntries index per
// spec-bearing row). With no usable slotMap, skip rather than misalign.
const useSlot = (measurements.length !== specRowCount)
&& Array.isArray(dscaTpl.slotMap) && dscaTpl.slotMap.length === specRowCount;
&& Array.isArray(tpl.slotMap) && tpl.slotMap.length === specRowCount;
if (measurements.length !== specRowCount && !useSlot) return null;
let h1 = setCol('', 12, 'Parameter');
h1 = setCol(h1, 31, 'Measured Value*');
h1 = setCol(h1, 51, 'Specification');
h1 = setCol(h1, 69, 'Status');
lines.push(h1);
// Final-Test header: use the verbatim captured header when present (8B/5B use
// two schemes — legacy "Measured Value" vs DSCA-style "Measured Value*"); else
// the DSCA-style header. The separator is a rule line (cosmetic, canonicalized).
if (tpl.ftHeader) {
lines.push(tpl.ftHeader);
} else {
let h1 = setCol('', 12, 'Parameter');
h1 = setCol(h1, 31, 'Measured Value*');
h1 = setCol(h1, 51, 'Specification');
h1 = setCol(h1, 69, 'Status');
lines.push(h1);
}
let h2 = setCol('', 4, '='.repeat(25));
h2 = setCol(h2, 31, '='.repeat(15));
h2 = setCol(h2, 48, '='.repeat(19));
h2 = setCol(h2, 69, '='.repeat(6));
lines.push(h2);
const is3345 = Array.isArray(dscaTpl.accHeader); // Hoffman-mined DSCA33/45
const is3345 = Array.isArray(tpl.accHeader); // Hoffman-mined (DSCA33/45 + 8B/5B)
let mi = 0, si = 0;
for (const row of dscaTpl.rows) {
for (const row of tpl.rows) {
const spec = (row.spec || '').trim();
let line = setCol('', 4, row.name);
if (spec) {
const su = splitSpecUnit(spec);
const m = useSlot
? formatMeasuredExact(parsed.statusEntries[dscaTpl.slotMap[si++]])
? formatMeasuredExact(parsed.statusEntries[tpl.slotMap[si++]])
: measurements[mi++];
if (m) {
// measured value right-justified ending at col 38, unit at col 40.
@@ -780,9 +814,9 @@ function generateExactDatasheet(record, specs) {
// Footer load note ("Standard output load for test is ... ohms.") — printed
// before the underline, only by the models whose staged original had it
// (captured per-model in STAGE 1; not all current-output models print it).
if (dscaTpl.loadNote) {
if (tpl.loadNote) {
lines.push('');
lines.push(TAB5 + dscaTpl.loadNote);
lines.push(TAB5 + tpl.loadNote);
}
} else {
// QB: TAB(12); "Parameter"; TAB(30); "Measured Value"; TAB(51); "Specification "; TAB(70); "Status"
@@ -837,8 +871,11 @@ function generateExactDatasheet(record, specs) {
} // end non-DSCA Final Test Results
// ---- Footer ----
// 240 VAC / Hi-Pot (conditional by family/model)
if (family === 'SCM5B') {
// 240 VAC / Hi-Pot (conditional by family/model). Templated models already render
// these as spec-less rows from the template, so skip the family-specific footer block.
if (tpl) {
// no footer Withstand/Hi-Pot block — handled in the template rows
} else if (family === 'SCM5B') {
const mn = (modelName || '').trim();
if (!mn.startsWith('SCM5BPT') && !mn.startsWith('SCM5B-1369')) {
lines.push(TAB5 + '240 VAC Withstand' + ''.padEnd(49) + 'PASS');

View File

@@ -0,0 +1,30 @@
// Enrich 8b5bscm-templates.json: capture each model's verbatim Final-Test HEADER line
// (the "Parameter ... Measured Value[*] ... Specification ... Status" line) from its
// _srcSerial Hoffman original, since 8B/5B use two different header schemes (legacy
// "Measured Value" vs DSCA-style "Measured Value*"). Stored as `ftHeader`. --apply writes.
const fs = require('fs'), https = require('https');
const TPL = './8b5bscm-templates.json';
const c = JSON.parse(fs.readFileSync('C:\\ProgramData\\dataforth-uploader\\credentials.json', 'utf8'));
const APPLY = process.argv.includes('--apply');
function req(m, uri, h, b) { return new Promise((res, rej) => { const u = new URL(uri); const r = https.request({ hostname: u.hostname, port: 443, path: u.pathname, method: m, headers: h, timeout: 30000 }, x => { let d = ''; x.on('data', c => d += c); x.on('end', () => { try { res(JSON.parse(d)); } catch { res({ _raw: d }); } }); }); r.on('error', rej); if (b) r.write(b); r.end(); }); }
(async () => {
const tpl = JSON.parse(fs.readFileSync(TPL, 'utf8'));
const form = Object.entries({ grant_type: 'client_credentials', client_id: c.CF_CLIENT_ID, client_secret: c.CF_CLIENT_SECRET, scope: c.CF_SCOPE }).map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v)).join('&');
const t = (await req('POST', c.CF_TOKEN_URL, { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(form) }, form)).access_token;
let done = 0; const schemes = {};
for (const m of Object.keys(tpl)) {
const sn = tpl[m]._srcSerial; if (!sn) continue;
const g = await req('GET', c.CF_API_BASE + '/api/v1/TestReportDataFiles/' + encodeURIComponent(sn), { Authorization: 'Bearer ' + t });
if (!g.Content) continue;
const L = g.Content.replace(/\r/g, '').split('\n');
const fi = L.findIndex(l => /FINAL TEST RESULTS/.test(l)); if (fi < 0) continue;
const hi = L.findIndex((l, i) => i > fi && /Parameter\s+Measured/.test(l)); if (hi < 0) continue;
tpl[m].ftHeader = L[hi];
const star = /Measured Value\*/.test(L[hi]) ? 'star' : 'plain';
schemes[star] = (schemes[star] || 0) + 1;
done++;
}
console.log('captured ftHeader for ' + done + ' models; header schemes: ' + JSON.stringify(schemes));
if (APPLY) { fs.writeFileSync(TPL, JSON.stringify(tpl)); console.log('[APPLY] wrote ' + TPL); }
else console.log('(dry run — pass --apply)');
})().catch(e => { console.error('ERR', e.message); process.exit(1); });

View File

@@ -6,7 +6,7 @@ process.env.DSCA_VALIDATE_MODE = '1';
const fs = require('fs'), https = require('https');
const db = require('./database/db');
const dse = require('./templates/datasheet-exact');
const TPL = './dsca33-45-templates.json';
const TPL = process.env.DSCA_TPL || './dsca33-45-templates.json';
const c = JSON.parse(fs.readFileSync('C:\\ProgramData\\dataforth-uploader\\credentials.json', 'utf8'));
const APPLY = process.argv.includes('--apply');
const only = process.argv.slice(2).filter(a => !a.startsWith('--'));

View File

@@ -0,0 +1,97 @@
// Fix 2 — validate DSCA33/45 Hoffman-mined renders against the live Hoffman originals.
// For each model: render its _srcSerial (an already-uploaded unit) via the new render
// path and content-normalized-compare it to GET /api/v1/TestReportDataFiles/{_srcSerial}.
// --apply marks passing models `validated:true` in dsca33-45-templates.json (the render
// gate). Read-only otherwise (no DB writes, no Hoffman writes).
process.env.DSCA_VALIDATE_MODE = '1'; // open the render gate for the compare
const fs = require('fs');
const https = require('https');
const db = require('./database/db');
const { renderContent } = require('./database/render-datasheet');
const TPL_PATH = process.env.DSCA_TPL || './dsca33-45-templates.json';
const CREDS_PATH = 'C:\\ProgramData\\dataforth-uploader\\credentials.json';
const APPLY = process.argv.includes('--apply');
const only = process.argv.slice(2).filter(a => !a.startsWith('--'));
function creds() { return JSON.parse(fs.readFileSync(CREDS_PATH, 'utf8')); }
function httpReq(method, uri, headers, body) {
return new Promise((resolve, reject) => {
const u = new URL(uri);
const req = https.request({ hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search, method, headers, timeout: 30000 }, res => {
let d = ''; res.on('data', c => d += c); res.on('end', () => { try { resolve({ status: res.statusCode, body: JSON.parse(d) }); } catch { resolve({ status: res.statusCode, body: { _raw: d } }); } });
});
req.on('error', reject); req.on('timeout', () => req.destroy(new Error('timeout')));
if (body) req.write(body); req.end();
});
}
async function getToken() {
const c = creds();
const form = Object.entries({ grant_type: 'client_credentials', client_id: c.CF_CLIENT_ID, client_secret: c.CF_CLIENT_SECRET, scope: c.CF_SCOPE })
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
const r = await httpReq('POST', c.CF_TOKEN_URL, { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(form) }, form);
if (r.status !== 200 || !r.body.access_token) throw new Error('token fail ' + r.status);
return r.body.access_token;
}
async function fetchOriginal(token, serial) {
const c = creds();
const r = await httpReq('GET', `${c.CF_API_BASE}/api/v1/TestReportDataFiles/${encodeURIComponent(serial)}`, { Authorization: 'Bearer ' + token });
if (r.status !== 200) return null;
return r.body && r.body.Content ? r.body.Content : null;
}
// content-normalize: collapse whitespace per line; DROP rule lines (pure separators —
// runs of = ~ _ -) and blank lines. Rule lines carry no content and their
// presence/position is the deferred cosmetic gap (e.g. the leading === letterhead line
// the originals have and our renders omit), so removing them isolates real content.
function norm(s) {
return s.replace(/\r/g, '').split('\n')
.map(l => l.trim())
.filter(t => t.length > 0 && !(/^[=~_\- ]+$/.test(t) && /[=~_\-]/.test(t)))
.map(t => t.replace(/\s+/g, ' '));
}
(async () => {
const tpl = JSON.parse(fs.readFileSync(TPL_PATH, 'utf8'));
const models = (only.length ? only : Object.keys(tpl)).filter(m => tpl[m]);
const token = await getToken();
const pass = [], fail = [], noOracle = [], noRec = [];
for (const m of models) {
const sn = tpl[m]._srcSerial;
if (!sn) { noOracle.push(m); continue; }
const original = await fetchOriginal(token, sn);
if (!original) { noOracle.push(m + '(no Hoffman ' + sn + ')'); continue; }
const rec = await db.queryOne('SELECT * FROM test_records WHERE serial_number=$1 AND model_number=$2 LIMIT 1', [sn, m]);
if (!rec) { noRec.push(m + '(' + sn + ')'); continue; }
let rendered; try { rendered = renderContent(rec); } catch (e) { rendered = null; }
if (!rendered) { fail.push({ m, sn, reason: 'render null' }); continue; }
const a = norm(rendered), b = norm(original);
let diff = -1; const mx = Math.max(a.length, b.length);
for (let i = 0; i < mx; i++) { if (a[i] !== b[i]) { diff = i; break; } }
if (diff === -1) pass.push(m);
else fail.push({ m, sn, line: diff, render: a[diff], golden: b[diff] });
}
console.log('\n=== DSCA33/45 Hoffman validation ===');
console.log('PASS (' + pass.length + '): ' + pass.join(', '));
console.log('\nFAIL (' + fail.length + '):');
for (const f of fail) {
if (f.reason) { console.log(' ' + f.m + ' (' + f.sn + '): ' + f.reason); continue; }
console.log(' ' + f.m + ' (' + f.sn + ') first diff L' + f.line);
console.log(' render: ' + JSON.stringify(f.render));
console.log(' golden: ' + JSON.stringify(f.golden));
}
if (noOracle.length) console.log('\nNO ORACLE: ' + noOracle.join(', '));
if (noRec.length) console.log('NO DB REC: ' + noRec.join(', '));
if (APPLY) {
const passSet = new Set(pass);
for (const m of Object.keys(tpl)) {
if (passSet.has(m)) tpl[m].validated = true;
else if (only.length === 0) delete tpl[m].validated; // full run: clear stale
}
fs.writeFileSync(TPL_PATH, JSON.stringify(tpl));
console.log('\n[APPLY] marked validated on ' + pass.length + ' models in ' + TPL_PATH);
} else {
console.log('\n(dry run — pass --apply to mark validated)');
}
await db.close();
})().catch(e => { console.error('ERR', e.message, e.stack); process.exit(1); });

View File

@@ -0,0 +1,24 @@
# Deploy — security.azcomputerguru.com
## 1. cPanel / WHM (IX server, 172.16.3.10)
- Create subdomain `security.azcomputerguru.com` (docroot e.g. `/home/<acct>/security`).
- Create MySQL DB `acgsec_assess` + user `acgsec_app`, grant all on the DB. Vault the password
(`msp-tools/security-assessment-db`). Import `app/schema.sql`.
- Upload `app/*` to the docroot. `cp config.sample.php config.php` and fill secrets (DB pass +
Mike's Syncro key from vault `msp-tools/syncro-mike`).
## 2. Cloudflare DNS + Access
- DNS: `security` A/CNAME -> the IX origin, **proxied** (orange cloud).
- Zero Trust > Access > Applications: add self-hosted app for `security.azcomputerguru.com`,
policy = Allow, include `mike@azcomputerguru.com` (require MFA). Everyone else blocked.
The app reads `Cf-Access-Authenticated-User-Email` as a second check.
## 3. Consent apps
- 365: register redirect URI `https://security.azcomputerguru.com/consent-callback.php` on the
Security Investigator app (bfbc12a4-...). Admin-consent link is generated per-tenant in-app.
- Google: create a Workspace read-only OAuth client, set GOOGLE_CLIENT_ID + redirect; scopes are
in config. (Stubbed until created.)
## Notes
- Read-only by design: the app only *reads* Syncro and *generates* consent links. No tenant writes.
- Post-meeting scan consumes the export + the granted 365/Google consent to run the audit.

View File

@@ -0,0 +1,12 @@
# ACG Security Assessment (security.azcomputerguru.com)
Single-assessor intake tool: identify the client by Syncro phone number, prefill what we can,
walk Mike through a risk-ordered questionnaire during the consult, capture read-only 365/Google
consent on the spot, and export the audit work-list for the post-meeting automated scan.
- `app/questions.json` — the question framework (sections, fields, sources, importance).
- `app/index.php` — the wizard UI (Cloudflare-Access gated).
- `app/api.php` — Syncro lookup, save/load, consent-link generation, export.
- `app/schema.sql` — MySQL schema. `DEPLOY.md` — hosting + Cloudflare Access steps.
Stack: PHP + MySQL on the IX cPanel host, behind Cloudflare Access (only mike@azcomputerguru.com).

View File

@@ -0,0 +1 @@
config.php

View File

@@ -0,0 +1,126 @@
<?php
// Backend for the ACG security assessment intake. JSON API behind Cloudflare Access.
require __DIR__ . '/config.php';
header('Content-Type: application/json');
$email = $_SERVER['HTTP_CF_ACCESS_AUTHENTICATED_USER_EMAIL'] ?? '';
if (ALLOWED_EMAIL !== '' && strcasecmp($email, ALLOWED_EMAIL) !== 0) {
http_response_code(403); echo json_encode(['error' => 'forbidden']); exit;
}
function db() {
static $pdo;
if (!$pdo) $pdo = new PDO('mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=utf8mb4', DB_USER, DB_PASS,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
return $pdo;
}
function body() { return json_decode(file_get_contents('php://input'), true) ?: []; }
function out($x) { echo json_encode($x); exit; }
function syncro($path) {
$url = rtrim(SYNCRO_BASE, '/') . $path . (strpos($path, '?') === false ? '?' : '&') . 'api_key=' . SYNCRO_KEY;
$ch = curl_init($url);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 20, CURLOPT_HTTPHEADER => ['Accept: application/json']]);
$raw = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
$raw = preg_replace('/[\x00-\x1F]/', ' ', $raw); // strip control chars Syncro leaks
return [$code, json_decode($raw, true)];
}
$action = $_GET['action'] ?? '';
// ---- Syncro client lookup by phone ----
if ($action === 'lookup') {
$phone = preg_replace('/\D/', '', body()['phone'] ?? '');
if (strlen($phone) < 7) out(['error' => 'phone too short']);
[$c, $res] = syncro('/customers?phone=' . urlencode($phone));
$cust = $res['customers'][0] ?? null;
if (!$cust) { [$c, $res] = syncro('/customers?query=' . urlencode($phone)); $cust = $res['customers'][0] ?? null; }
if (!$cust) out(['error' => 'no Syncro customer for that phone']);
$domain = '';
if (!empty($cust['email']) && strpos($cust['email'], '@') !== false) $domain = substr(strrchr($cust['email'], '@'), 1);
out(['prefill' => [
'business_name' => $cust['business_name'] ?: trim(($cust['firstname'] ?? '') . ' ' . ($cust['lastname'] ?? '')),
'syncro_customer_id' => $cust['id'] ?? '',
'address' => trim(($cust['address'] ?? '') . ' ' . ($cust['city'] ?? '') . ' ' . ($cust['state'] ?? '') . ' ' . ($cust['zip'] ?? '')),
'primary_contact' => trim(($cust['firstname'] ?? '') . ' ' . ($cust['lastname'] ?? '')),
'contact_email' => $cust['email'] ?? '',
'email_domains' => $domain ? [$domain] : [],
'tenant_domain' => $domain,
'assessment_date' => date('Y-m-d'),
], 'rmm' => null]);
}
// ---- Consent link generation ----
if ($action === 'consent') {
$b = body(); $provider = $b['provider'] ?? ''; $domain = trim($b['domain'] ?? '');
if ($provider === 'm365') {
if (!$domain) out(['error' => 'need tenant domain']);
// Admin-consent prompt for our read-only Security Investigator app (multi-tenant).
out(['url' => 'https://login.microsoftonline.com/' . rawurlencode($domain)
. '/adminconsent?client_id=' . M365_INVESTIGATOR_APP_ID
. '&redirect_uri=' . rawurlencode(CONSENT_REDIRECT)]);
}
if ($provider === 'google') {
if (GOOGLE_CLIENT_ID === '') out(['error' => 'Google client not configured yet']);
out(['url' => 'https://accounts.google.com/o/oauth2/v2/auth?client_id=' . rawurlencode(GOOGLE_CLIENT_ID)
. '&response_type=code&access_type=offline&prompt=consent'
. '&scope=' . rawurlencode(GOOGLE_SCOPES)
. '&redirect_uri=' . rawurlencode(GOOGLE_REDIRECT)
. ($domain ? '&hd=' . rawurlencode($domain) : '')]);
}
out(['error' => 'unknown provider']);
}
// ---- Save / upsert ----
if ($action === 'save') {
$b = body();
$data = json_encode($b['data'] ?? []);
$consent = json_encode($b['consent'] ?? []);
if (!empty($b['id'])) {
db()->prepare('UPDATE assessments SET phone=?, business_name=?, data=?, consent=?, updated=NOW() WHERE id=?')
->execute([$b['phone'] ?? '', $b['business_name'] ?? '', $data, $consent, $b['id']]);
out(['id' => (int)$b['id']]);
}
db()->prepare('INSERT INTO assessments (phone, business_name, data, consent, created, updated) VALUES (?,?,?,?,NOW(),NOW())')
->execute([$b['phone'] ?? '', $b['business_name'] ?? '', $data, $consent]);
out(['id' => (int)db()->lastInsertId()]);
}
// ---- Load ----
if ($action === 'load') {
$row = db()->prepare('SELECT data, consent FROM assessments WHERE id=?');
$row->execute([$_GET['id'] ?? 0]); $r = $row->fetch();
out($r ? ['data' => json_decode($r['data'], true), 'consent' => json_decode($r['consent'], true)] : ['error' => 'not found']);
}
// ---- List ----
if ($action === 'list') {
$rows = db()->query('SELECT id, phone, business_name, DATE_FORMAT(updated, "%Y-%m-%d %H:%i") updated FROM assessments ORDER BY updated DESC LIMIT 100')->fetchAll();
out(['items' => $rows]);
}
// ---- Export (HTML report-ready intake) ----
if ($action === 'export') {
$row = db()->prepare('SELECT * FROM assessments WHERE id=?'); $row->execute([$_GET['id'] ?? 0]); $r = $row->fetch();
if (!$r) { http_response_code(404); exit('not found'); }
$data = json_decode($r['data'], true); $consent = json_decode($r['consent'], true);
$q = json_decode(file_get_contents(__DIR__ . '/questions.json'), true);
header('Content-Type: text/html; charset=utf-8');
echo '<!doctype html><meta charset=utf-8><title>Assessment intake — ' . htmlspecialchars($r['business_name']) . '</title>';
echo '<style>body{font:14px/1.5 system-ui;max-width:820px;margin:30px auto;padding:0 16px;color:#0f172a}h1{margin-bottom:2px}h2{margin-top:26px;border-bottom:2px solid #e3e8ee;padding-bottom:4px}.r{display:grid;grid-template-columns:240px 1fr;gap:8px;padding:4px 0}.k{color:#64748b}@media print{body{margin:0}}</style>';
echo '<h1>' . htmlspecialchars($r['business_name'] ?: 'Client') . '</h1><div class=k>Security assessment intake · ' . $r['updated'] . '</div>';
foreach ($q['sections'] as $s) {
$rows = '';
foreach ($s['fields'] as $f) {
if (($f['type'] ?? '') === 'hidden') continue;
$v = $data[$f['id']] ?? '';
if (($f['type'] ?? '') === 'consent') { $cc = $consent[$f['provider']] ?? []; $v = !empty($cc['granted']) ? 'CONSENTED' : (!empty($cc['url']) ? 'link sent' : ''); }
if (is_array($v)) $v = implode(', ', $v);
if ($v !== '' && $v !== null) $rows .= '<div class=r><div class=k>' . htmlspecialchars($f['label']) . '</div><div>' . htmlspecialchars($v) . '</div></div>';
}
if ($rows) echo '<h2>' . htmlspecialchars($s['title']) . '</h2>' . $rows;
}
exit;
}
out(['error' => 'unknown action']);

View File

@@ -0,0 +1,15 @@
<?php
// Copy to config.php on the server (config.php is gitignored). Secrets live in the SOPS vault.
define('ALLOWED_EMAIL', 'mike@azcomputerguru.com'); // defense-in-depth; Cloudflare Access is the primary gate
define('DB_HOST', 'localhost');
define('DB_NAME', 'acgsec_assess');
define('DB_USER', 'acgsec_app');
define('DB_PASS', 'CHANGE_ME'); // vault: msp-tools/security-assessment-db
define('SYNCRO_BASE', 'https://computerguru.syncromsp.com/api/v1');
define('SYNCRO_KEY', 'CHANGE_ME'); // vault: msp-tools/syncro-mike (Mike's per-user key)
// Read-only "ComputerGuru Security Investigator" multi-tenant app (from the remediation tool):
define('M365_INVESTIGATOR_APP_ID', 'bfbc12a4-f0dd-4e12-b06d-997e7271e10c');
define('CONSENT_REDIRECT', 'https://security.azcomputerguru.com/consent-callback.php');
define('GOOGLE_CLIENT_ID', ''); // set once a Workspace read-only OAuth client exists
define('GOOGLE_SCOPES', 'https://www.googleapis.com/auth/admin.directory.user.readonly https://www.googleapis.com/auth/admin.reports.audit.readonly');
define('GOOGLE_REDIRECT', 'https://security.azcomputerguru.com/google-callback.php');

View File

@@ -0,0 +1,201 @@
<?php
// security.azcomputerguru.com — ACG Client Security Assessment intake.
// Access is gated by Cloudflare Access (only mike@azcomputerguru.com). Defense-in-depth:
// re-check the Cf-Access-Authenticated-User-Email header here too.
require __DIR__ . '/config.php';
$email = $_SERVER['HTTP_CF_ACCESS_AUTHENTICATED_USER_EMAIL'] ?? '';
if (ALLOWED_EMAIL !== '' && strcasecmp($email, ALLOWED_EMAIL) !== 0) {
http_response_code(403);
exit('Forbidden — this tool is restricted.');
}
?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ACG Security Assessment</title>
<style>
:root{
--bg:#0f172a; --panel:#111c33; --surface:#ffffff; --ink:#0f172a; --ink-2:#475569; --ink-3:#94a3b8;
--line:#e3e8ee; --accent:#1d4ed8; --accent-soft:#eef4ff; --good:#16a34a; --good-bg:#dcfce7;
--warn:#b45309; --warn-bg:#fef3c7; --crit:#b91c1c; --crit-bg:#fee2e2;
--sans:Inter,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; --r:8px;
}
*{box-sizing:border-box} html,body{height:100%;margin:0}
body{font-family:var(--sans);color:var(--ink);background:#f1f5f9;display:grid;grid-template-columns:260px 1fr;grid-template-rows:56px 1fr;grid-template-areas:"brand top" "rail main";height:100vh;overflow:hidden}
button,input,select,textarea{font-family:inherit}
/* brand + topbar */
.brand{grid-area:brand;background:var(--bg);color:#fff;display:flex;align-items:center;gap:10px;padding:0 18px;font-weight:700;letter-spacing:-.01em}
.brand .sh{width:22px;height:22px;border-radius:5px;background:linear-gradient(135deg,#3b82f6,#1d4ed8);display:inline-block}
.top{grid-area:top;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:14px;padding:0 18px}
.top .client{font-weight:600}.top .muted{color:var(--ink-3)}
.top .sp{flex:1}
.top button{height:34px;padding:0 13px;border:1px solid var(--line);border-radius:var(--r);background:#fff;cursor:pointer;font-size:13px;color:var(--ink)}
.top button:hover{background:#f1f5f9}.top button.pri{background:var(--accent);color:#fff;border-color:var(--accent)}
/* rail */
.rail{grid-area:rail;background:var(--panel);color:#cbd5e1;padding:14px 10px;overflow-y:auto}
.rail .step{display:flex;align-items:center;gap:11px;padding:9px 11px;border-radius:var(--r);cursor:pointer;font-size:13.5px;color:#cbd5e1}
.rail .step:hover{background:#1b2a47}
.rail .step.active{background:#1d4ed8;color:#fff}
.rail .step .n{width:22px;height:22px;border-radius:50%;background:#24344f;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;flex-shrink:0}
.rail .step.active .n{background:#fff;color:#1d4ed8}
.rail .step.done .n{background:var(--good);color:#fff}
.rail .step .pct{margin-left:auto;font-size:10.5px;color:#64748b}
.rail .step.active .pct{color:#bfdbfe}
/* main */
.main{grid-area:main;overflow-y:auto;padding:26px 34px;display:flex;justify-content:center}
.sheet{width:100%;max-width:760px}
.sec-h{display:flex;align-items:baseline;gap:12px;margin:0 0 4px}
.sec-h h1{font-size:21px;margin:0;letter-spacing:-.01em}
.sec-h .ix{font-size:12px;color:var(--ink-3);font-weight:600}
.intro{color:var(--ink-2);font-size:13.5px;margin:0 0 18px}
.field{margin-bottom:16px;background:#fff;border:1px solid var(--line);border-radius:10px;padding:13px 15px}
.field.crit{border-left:3px solid var(--crit)} .field.high{border-left:3px solid var(--warn)}
.field label{display:block;font-size:13.5px;font-weight:600;margin-bottom:6px}
.field .help{font-size:12px;color:var(--ink-3);margin-top:5px}
.field .tag-src{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:1px 6px;border-radius:4px;margin-left:7px;vertical-align:middle}
.src-syncro{background:#e0e7ff;color:#3730a3}.src-rmm{background:#dcfce7;color:#166534}.src-scan{background:#f1f5f9;color:#64748b}.src-ask{background:#fff7ed;color:#9a3412}
.field input[type=text],.field input[type=email],.field input[type=number],.field input[type=tel],.field input[type=date],.field select,.field textarea{
width:100%;height:38px;border:1px solid #cbd5e1;border-radius:7px;padding:0 11px;font-size:14px;background:#fff;color:var(--ink)}
.field textarea{height:74px;padding:9px 11px;resize:vertical}
.field input:focus,.field select:focus,.field textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
.opts{display:flex;flex-wrap:wrap;gap:7px}
.opt{font-size:13px;padding:7px 12px;border:1px solid #cbd5e1;border-radius:20px;background:#fff;cursor:pointer;user-select:none}
.opt.on{background:var(--accent);color:#fff;border-color:var(--accent)}
.lookup{display:flex;gap:8px}.lookup input{flex:1}
.lookup button{height:38px;padding:0 16px;border:0;border-radius:7px;background:var(--accent);color:#fff;font-weight:600;cursor:pointer}
.consent{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.consent button{height:36px;padding:0 13px;border:1px solid var(--accent);border-radius:7px;background:var(--accent-soft);color:var(--accent);font-weight:600;cursor:pointer}
.consent .link{font-size:12px;word-break:break-all;background:#f8fafc;border:1px dashed #cbd5e1;border-radius:6px;padding:7px 9px;flex:1;min-width:240px}
.consent .status{font-size:12px;font-weight:700;padding:2px 9px;border-radius:20px}
.consent .status.pending{background:var(--warn-bg);color:var(--warn)} .consent .status.done{background:var(--good-bg);color:var(--good)}
.navbar{display:flex;gap:10px;margin-top:24px;align-items:center}
.navbar .sp{flex:1}
.navbar button{height:40px;padding:0 20px;border-radius:8px;border:1px solid var(--line);background:#fff;cursor:pointer;font-size:14px;font-weight:600}
.navbar button.pri{background:var(--accent);color:#fff;border-color:var(--accent)}
.navbar button:disabled{opacity:.5;cursor:default}
.savedot{font-size:12px;color:var(--ink-3)}
.review .row{display:grid;grid-template-columns:230px 1fr;gap:10px;padding:7px 0;border-bottom:1px solid var(--line);font-size:13.5px}
.review .row .k{color:var(--ink-3)} .review .row .v{font-weight:500}
.review h3{margin:18px 0 4px;font-size:14px}
</style>
</head>
<body>
<div class="brand"><span class="sh"></span> ACG&nbsp;Security</div>
<div class="top">
<span class="client" id="clientName">No client selected</span>
<span class="muted" id="clientSub"></span>
<span class="sp"></span>
<span class="savedot" id="saveDot"></span>
<button id="btnList">Assessments</button>
<button class="pri" id="btnExport">Export</button>
</div>
<nav class="rail" id="rail"></nav>
<main class="main"><div class="sheet" id="sheet"></div></main>
<script>
const ICONS={building:'🏢',target:'🎯',key:'🔑',shield:'🛡️',monitor:'🖥️',globe:'🌐',users:'👥',cloud:'☁️',lock:'🔒'};
const state={q:null,sec:0,data:{},id:null,consent:{}};
const $=s=>document.querySelector(s);
const api=(action,body)=>fetch('api.php?action='+action,{method:body?'POST':'GET',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined}).then(r=>r.json());
async function boot(){
state.q=await fetch('questions.json').then(r=>r.json());
const u=new URLSearchParams(location.search); if(u.get('id')) await load(u.get('id'));
renderRail(); renderSection();
}
function secPct(s){const ask=s.fields.filter(f=>!['hidden','auto'].includes(f.source||'')&&f.type!=='hidden');const done=ask.filter(f=>{const v=state.data[f.id];return Array.isArray(v)?v.length:(v!==undefined&&v!=='')}).length;return ask.length?Math.round(done/ask.length*100):0;}
function renderRail(){
$('#rail').innerHTML=state.q.sections.map((s,i)=>{
const pct=secPct(s);const cls=i===state.sec?'active':(pct===100?'done':'');
return `<div class="step ${cls}" data-i="${i}"><span class="n">${pct===100&&i!==state.sec?'✓':i+1}</span><span>${s.title}</span><span class="pct">${pct}%</span></div>`;
}).join('')+`<div class="step ${state.sec===state.q.sections.length?'active':''}" data-i="${state.q.sections.length}"><span class="n">✓</span><span>Review &amp; finish</span></div>`;
document.querySelectorAll('.rail .step').forEach(el=>el.onclick=()=>{state.sec=+el.dataset.i;renderRail();renderSection();});
}
function field(f){
if(f.type==='hidden')return'';
const v=state.data[f.id];
const src=f.source&&f.source!=='input'&&f.source!=='auto'?`<span class="tag-src src-${f.source}">${f.source}</span>`:'';
const cls='field'+(f.importance==='critical'?' crit':f.importance==='high'?' high':'');
let inner='';
if(f.type==='consent'){inner=consentField(f);}
else if(f.lookup){inner=`<div class="lookup"><input type="tel" id="fld_${f.id}" value="${v||''}" placeholder="(520) 555-1234"><button onclick="doLookup()">Look up</button></div>`;}
else if(f.type==='multiselect'){inner=`<div class="opts">`+f.options.map(o=>`<span class="opt ${(v||[]).includes(o)?'on':''}" data-f="${f.id}" data-o="${o}">${o}</span>`).join('')+`</div>`;}
else if(f.type==='select'){inner=`<select id="fld_${f.id}"><option value="">— select —</option>`+f.options.map(o=>`<option ${v===o?'selected':''}>${o}</option>`).join('')+`</select>`;}
else if(f.type==='boolean'){inner=`<div class="opts">${['Yes','No','Unsure'].map(o=>`<span class="opt ${v===o?'on':''}" data-rf="${f.id}" data-o="${o}">${o}</span>`).join('')}</div>`;}
else if(f.type==='textarea'){inner=`<textarea id="fld_${f.id}">${v||''}</textarea>`;}
else if(f.type==='tags'){inner=`<input type="text" id="fld_${f.id}" value="${Array.isArray(v)?v.join(', '):(v||'')}" placeholder="comma separated">`;}
else {const t=f.type==='number'?'number':f.type==='email'?'email':f.type==='date'?'date':'text';inner=`<input type="${t}" id="fld_${f.id}" value="${v||''}">`;}
return `<div class="${cls}"><label>${f.label}${src}</label>${inner}${f.help?`<div class="help">${f.help}</div>`:''}</div>`;
}
function consentField(f){
const c=state.consent[f.provider]||{};
const status=c.granted?`<span class="status done">✓ consented</span>`:c.url?`<span class="status pending">awaiting click</span>`:'';
return `<div class="consent"><button onclick="genConsent('${f.provider}')">${c.url?'Regenerate':'Generate'} ${f.provider==='m365'?'365':'Google'} link</button>${c.url?`<span class="link">${c.url}</span>`:''}${status}<button onclick="markConsent('${f.provider}')" title="Mark as consented">Mark done</button></div>`;
}
function renderSection(){
const secs=state.q.sections;
if(state.sec>=secs.length){return renderReview();}
const s=secs[state.sec];
$('#sheet').innerHTML=`<div class="sec-h"><span style="font-size:22px">${ICONS[s.icon]||''}</span><h1>${s.title}</h1><span class="ix">${state.sec+1} / ${secs.length}</span></div>${s.intro?`<p class="intro">${s.intro}</p>`:''}<div id="fields">${s.fields.map(field).join('')}</div>${navbar()}`;
wire(s);
}
function navbar(last){
return `<div class="navbar"><button onclick="go(-1)" ${state.sec===0?'disabled':''}>← Back</button><span class="sp"></span><button onclick="saveNow()">Save</button><button class="pri" onclick="go(1)">${last?'Done':'Next →'}</button></div>`;
}
function wire(s){
s.fields.forEach(f=>{
const el=document.getElementById('fld_'+f.id);
if(el)el.onchange=()=>{let v=el.value;if(f.type==='tags')v=v.split(',').map(x=>x.trim()).filter(Boolean);state.data[f.id]=v;debouncedSave();renderRail();};
});
document.querySelectorAll('.opt[data-f]').forEach(el=>el.onclick=()=>{const fid=el.dataset.f,o=el.dataset.o;const a=state.data[fid]||[];const i=a.indexOf(o);i<0?a.push(o):a.splice(i,1);state.data[fid]=a;el.classList.toggle('on');debouncedSave();renderRail();});
document.querySelectorAll('.opt[data-rf]').forEach(el=>el.onclick=()=>{const fid=el.dataset.rf;state.data[fid]=el.dataset.o;document.querySelectorAll(`.opt[data-rf="${fid}"]`).forEach(x=>x.classList.toggle('on',x===el));debouncedSave();renderRail();});
}
function go(d){state.sec=Math.max(0,Math.min(state.q.sections.length,state.sec+d));saveNow();renderRail();renderSection();window.scrollTo(0,0);}
async function doLookup(){
const phone=$('#fld_syncro_phone').value.trim(); if(!phone)return;
$('#clientSub').textContent='looking up…';
const r=await api('lookup',{phone});
if(r.error){$('#clientSub').textContent='not found: '+r.error;return;}
// prefill syncro-sourced fields
Object.assign(state.data,r.prefill||{});
state.data.syncro_phone=phone;
$('#clientName').textContent=r.prefill.business_name||'Client';
$('#clientSub').textContent=(r.prefill.address||'')+(r.rmm?` · ${r.rmm.workstations||0} ws / ${r.rmm.servers||0} srv`:'');
if(r.rmm){state.data.workstation_count=r.rmm.workstations;state.data.server_count=r.rmm.servers;}
saveNow();renderSection();renderRail();
}
async function genConsent(provider){
const r=await api('consent',{provider,domain:state.data.tenant_domain||(state.data.email_domains||[])[0]||'',customer_id:state.data.syncro_customer_id});
if(r.error){alert('Consent link failed: '+r.error);return;}
state.consent[provider]={url:r.url,granted:false};saveNow();renderSection();
}
function markConsent(provider){state.consent[provider]=Object.assign(state.consent[provider]||{},{granted:true});saveNow();renderSection();}
function renderReview(){
let html=`<div class="sec-h"><span style="font-size:22px">✅</span><h1>Review &amp; finish</h1></div><p class="intro">Confirm the captured intake. Export hands the audit work-list to the post-meeting scan.</p><div class="review">`;
for(const s of state.q.sections){
const rows=s.fields.filter(f=>f.type!=='hidden').map(f=>{let v=state.data[f.id];if(f.type==='consent'){const c=state.consent[f.provider]||{};v=c.granted?'✓ consented':(c.url?'link sent':'—');}if(Array.isArray(v))v=v.join(', ');return (v&&v!=='')?`<div class="row"><div class="k">${f.label}</div><div class="v">${v}</div></div>`:'';}).filter(Boolean).join('');
if(rows)html+=`<h3>${ICONS[s.icon]||''} ${s.title}</h3>${rows}`;
}
html+=`</div>${navbar(true)}`;
$('#sheet').innerHTML=html;
}
let saveT=null;
function debouncedSave(){clearTimeout(saveT);saveT=setTimeout(saveNow,800);}
async function saveNow(){
$('#saveDot').textContent='saving…';
const payload={id:state.id,phone:state.data.syncro_phone||'',business_name:state.data.business_name||'',data:state.data,consent:state.consent};
const r=await api('save',payload); if(r.id)state.id=r.id;
history.replaceState(null,'','?id='+state.id);
$('#saveDot').textContent='saved ✓';setTimeout(()=>$('#saveDot').textContent='',1500);
}
async function load(id){const r=await api('load&id='+id);if(r&&r.data){state.id=id;state.data=r.data;state.consent=r.consent||{};$('#clientName').textContent=state.data.business_name||'Client';}}
$('#btnExport').onclick=()=>{window.open('api.php?action=export&id='+(state.id||''),'_blank');};
$('#btnList').onclick=async()=>{const r=await api('list');alert((r.items||[]).map(x=>`#${x.id} ${x.business_name||x.phone} (${x.updated})`).join('\n')||'No assessments yet');};
boot();
</script>
</body>
</html>

View File

@@ -0,0 +1,155 @@
{
"version": 1,
"title": "ACG Client Security Assessment",
"note": "Consult intake. Fields marked source=syncro/rmm prefill automatically; source=scan are filled by the post-meeting automated scan (do not ask). Ask only what a human knows.",
"sections": [
{
"id": "identify",
"title": "Client",
"icon": "building",
"intro": "Enter the client's primary phone number to pull their record from Syncro.",
"fields": [
{ "id": "syncro_phone", "label": "Primary phone (Syncro lookup)", "type": "phone", "source": "input", "lookup": true, "required": true, "help": "Identifies the client. Pulls name, address, contact, domain, asset count." },
{ "id": "business_name", "label": "Business name", "type": "text", "source": "syncro" },
{ "id": "syncro_customer_id", "label": "Syncro customer ID", "type": "hidden", "source": "syncro" },
{ "id": "address", "label": "Address", "type": "text", "source": "syncro" },
{ "id": "primary_contact", "label": "Primary contact", "type": "text", "source": "syncro" },
{ "id": "contact_email", "label": "Contact email", "type": "email", "source": "syncro" },
{ "id": "assessment_date", "label": "Assessment date", "type": "date", "source": "auto" }
]
},
{
"id": "scope",
"title": "Scope & Context",
"icon": "target",
"intro": "Sets the scope and risk weighting for the whole audit.",
"fields": [
{ "id": "email_domains", "label": "Email / primary domain(s)", "type": "tags", "source": "syncro", "required": true, "help": "Used to target the 365/Google tenant + DNS/DMARC checks." },
{ "id": "industry", "label": "Industry", "type": "text", "source": "ask" },
{ "id": "employees", "label": "# employees", "type": "number", "source": "ask" },
{ "id": "users", "label": "# of computer/email users", "type": "number", "source": "ask" },
{ "id": "compliance", "label": "Compliance drivers", "type": "multiselect", "source": "ask", "importance": "critical",
"options": ["HIPAA", "PCI-DSS", "CMMC / DFARS", "SOC 2", "GLBA", "FTC Safeguards", "Cyber-insurance requirement", "None / unsure"],
"help": "Drives required controls and report framing." },
{ "id": "sensitive_data", "label": "Sensitive data handled", "type": "multiselect", "source": "ask", "importance": "critical",
"options": ["PHI (health)", "PII (personal)", "Cardholder / payment", "Financial records", "Legal / privileged", "Intellectual property", "None significant"] },
{ "id": "reason", "label": "Reason for assessment", "type": "select", "source": "ask",
"options": ["Proactive / baseline", "Cyber-insurance renewal", "Compliance requirement", "Recent incident / scare", "Client request / concern", "M&A / due diligence"] },
{ "id": "past_incidents", "label": "Known past incidents or breaches?", "type": "textarea", "source": "ask", "help": "Any ransomware, BEC, account takeover, data loss." }
]
},
{
"id": "identity",
"title": "Identity & Email",
"icon": "key",
"intro": "Top attack surface. Capture consent here so the scan can run after the meeting.",
"fields": [
{ "id": "mail_platform", "label": "Email platform", "type": "select", "source": "ask", "importance": "critical",
"options": ["Microsoft 365", "Google Workspace", "On-prem Exchange", "Hybrid (365 + on-prem)", "Other / unsure"], "drivesConsent": true },
{ "id": "tenant_domain", "label": "Tenant / workspace primary domain", "type": "text", "source": "syncro", "help": "Used to resolve the tenant ID + generate the consent link." },
{ "id": "mailbox_count", "label": "# mailboxes / users", "type": "number", "source": "ask" },
{ "id": "global_admins", "label": "# of global/super admins", "type": "number", "source": "ask", "help": "Shared admin accounts are a flag." },
{ "id": "shared_admin", "label": "Shared admin account(s) in use?", "type": "boolean", "source": "ask" },
{ "id": "mfa_belief", "label": "MFA enforced for all users? (their understanding)", "type": "select", "source": "ask",
"options": ["Yes, all users", "Admins only", "Some users", "No", "Unsure"], "help": "Scan verifies the reality." },
{ "id": "conditional_access", "label": "Conditional Access / security defaults?", "type": "select", "source": "ask",
"options": ["Conditional Access policies", "Security defaults on", "Neither", "Unsure"] },
{ "id": "mail_security_layer", "label": "Email security layer", "type": "multiselect", "source": "ask",
"options": ["Mailprotector / CloudFilter", "INKY", "Proofpoint", "Mimecast", "Native EOP / Defender", "None"] },
{ "id": "consent_365", "label": "365 read-only assessment consent", "type": "consent", "provider": "m365", "source": "consent",
"help": "Generates the admin-consent link for the client to approve on the spot." },
{ "id": "consent_google", "label": "Google Workspace read-only consent", "type": "consent", "provider": "google", "source": "consent" }
]
},
{
"id": "backup",
"title": "Backup & Recovery",
"icon": "shield",
"intro": "Business-critical: the difference between an incident and a catastrophe.",
"fields": [
{ "id": "backup_solution", "label": "Backup solution(s)", "type": "text", "source": "ask", "importance": "high" },
{ "id": "backup_scope", "label": "What is backed up", "type": "multiselect", "source": "ask",
"options": ["Servers", "Microsoft 365 / Google data", "Workstations", "NAS / file shares", "Nothing formal"] },
{ "id": "backup_offsite", "label": "Offsite / immutable copy?", "type": "select", "source": "ask",
"options": ["Yes, immutable/air-gapped", "Yes, offsite (mutable)", "Local only", "Unsure"] },
{ "id": "backup_tested", "label": "Last tested restore", "type": "select", "source": "ask",
"options": ["< 3 months", "3-12 months", "> 12 months / never", "Unsure"], "importance": "high" },
{ "id": "rto_rpo", "label": "Recovery expectations (RTO / RPO)", "type": "text", "source": "ask", "help": "How long down / how much data loss is tolerable." }
]
},
{
"id": "endpoints",
"title": "Endpoints",
"icon": "monitor",
"intro": "Counts prefill from GuruRMM where the client is enrolled.",
"fields": [
{ "id": "workstation_count", "label": "# workstations", "type": "number", "source": "rmm" },
{ "id": "server_count", "label": "# servers", "type": "number", "source": "rmm" },
{ "id": "os_mix", "label": "OS mix / any end-of-life?", "type": "multiselect", "source": "rmm",
"options": ["Windows 11", "Windows 10", "Windows 7/8 (EOL)", "Windows Server 2016+", "Server 2012/older (EOL)", "macOS", "Linux"], "importance": "high" },
{ "id": "edr", "label": "Endpoint protection / EDR", "type": "multiselect", "source": "ask",
"options": ["Microsoft Defender", "GuruRMM / managed AV", "SentinelOne", "CrowdStrike", "Other AV", "None / unmanaged"] },
{ "id": "patching", "label": "Patch management", "type": "select", "source": "ask",
"options": ["Managed (us / RMM)", "Managed (other MSP)", "WSUS / internal", "Manual / ad-hoc", "None"] },
{ "id": "disk_encryption", "label": "Disk encryption (BitLocker/FileVault)?", "type": "select", "source": "ask",
"options": ["Yes, enforced", "Some devices", "No", "Unsure"] },
{ "id": "local_admin", "label": "Users have local admin rights?", "type": "select", "source": "ask",
"options": ["No (standard users)", "Some", "Yes (most)", "Unsure"] }
]
},
{
"id": "network",
"title": "Network & Perimeter",
"icon": "globe",
"intro": "External exposure. Exposed RDP is the #1 ransomware entry point.",
"fields": [
{ "id": "firewall", "label": "Firewall make/model", "type": "text", "source": "ask" },
{ "id": "remote_access", "label": "Remote access method(s)", "type": "multiselect", "source": "ask",
"options": ["VPN (UniFi/firewall)", "RDP exposed to internet", "RMM remote (us)", "TeamViewer/AnyDesk", "RD Gateway", "None"], "importance": "critical" },
{ "id": "port_forwards", "label": "Known port-forwards / open inbound services?", "type": "textarea", "source": "ask", "help": "Scan verifies via external port check." },
{ "id": "guest_wifi", "label": "Guest WiFi separated from internal?", "type": "select", "source": "ask",
"options": ["Yes, isolated VLAN/SSID", "Shared with internal", "No guest WiFi", "Unsure"] },
{ "id": "sites", "label": "# of physical sites", "type": "number", "source": "ask" }
]
},
{
"id": "access",
"title": "Access & Operations",
"icon": "users",
"intro": "People and process — where most breaches actually start.",
"fields": [
{ "id": "password_policy", "label": "Password policy / password manager", "type": "select", "source": "ask",
"options": ["Enforced policy + password manager", "Policy only", "Password manager only", "Neither / unsure"] },
{ "id": "offboarding", "label": "Onboarding/offboarding process documented?", "type": "select", "source": "ask",
"options": ["Yes, documented", "Informal", "No"] },
{ "id": "service_accounts", "label": "Shared / service accounts?", "type": "textarea", "source": "ask" },
{ "id": "vendor_access", "label": "Third-party / vendor access to systems?", "type": "textarea", "source": "ask" },
{ "id": "awareness_training", "label": "Security awareness training?", "type": "select", "source": "ask",
"options": ["Yes, ongoing (KnowBe4/etc.)", "One-time", "None"], "importance": "high" }
]
},
{
"id": "cloud",
"title": "Cloud, SaaS & DNS",
"icon": "cloud",
"fields": [
{ "id": "file_sharing", "label": "File sharing / storage", "type": "multiselect", "source": "ask",
"options": ["SharePoint / OneDrive", "Google Drive", "Dropbox", "On-prem file server", "Egnyte / other"] },
{ "id": "critical_saas", "label": "Other critical SaaS apps", "type": "tags", "source": "ask", "help": "Line-of-business apps, accounting, CRM, etc." },
{ "id": "dns_control", "label": "Who controls DNS + domain registrar?", "type": "text", "source": "ask", "help": "Registrar hijack is a top BEC vector." }
]
},
{
"id": "physical",
"title": "Physical & Insurance",
"icon": "lock",
"fields": [
{ "id": "physical_security", "label": "Server/network gear physically secured?", "type": "select", "source": "ask",
"options": ["Locked room/rack", "Office only", "Open / exposed", "Unsure"] },
{ "id": "byod", "label": "BYOD / personal devices on company data?", "type": "select", "source": "ask",
"options": ["No / managed only", "Allowed, managed (MDM)", "Allowed, unmanaged", "Unsure"] },
{ "id": "cyber_insurance", "label": "Cyber-insurance carrier", "type": "text", "source": "ask", "help": "Carrier questionnaires often dictate required controls." }
]
}
]
}

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS assessments (
id INT AUTO_INCREMENT PRIMARY KEY,
phone VARCHAR(32),
business_name VARCHAR(255),
data JSON,
consent JSON,
created DATETIME,
updated DATETIME,
INDEX idx_phone (phone),
INDEX idx_updated (updated)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -47,6 +47,8 @@ sources:
- clients/cascades-tucson/session-logs/2026-06/2026-06-16-howard-vertical-voice-vlan-plan.md
- clients/cascades-tucson/docs/network/voice-vlan-cutover.md
- clients/cascades-tucson/docs/network/voice-phone-inventory.md
- clients/cascades-tucson/docs/network/network-logging-plan.md
- clients/cascades-tucson/session-logs/2026-06/2026-06-18-howard-voice-vlan-migration-logging-plan.md
- clients/cascades-tucson/reports/2026-06-16-unifi-full-audit.md
- clients/cascades-tucson/reports/2026-06-16-2.4ghz-remediation-runbook.md
- clients/cascades-tucson/docs/overview.md
@@ -425,6 +427,7 @@ Syncro live pull 2026-06-18: **0 open tickets.** No hours drawn from the 2026-06
- **[URGENT] Order replacement workstation for Lupe Sanchez (DESKTOP-TRCIEJA).** Decision made 2026-06-18. EOL Gateway ZX6971 / i3-2120 / 8 GB / Win11-unsupported. On new machine: provision GuruRMM + Bitdefender only; do NOT carry over the Datto stack.
- **[URGENT] Rotate exposed Synology Cloud Signin Portal credential.** Vault commit 1fbc0e1 committed it plaintext; encrypted go-forward but credential is exposed in git history. Also verify MDM service account + WiFi CSCNet from that same commit were never plaintext.
- **[IN PROGRESS] Voice VLAN (VLAN 30) AudioCodes cutover: 0/8 remaining.** 22/22 Poly + Vertical desktop DONE. Flip USW-16-PoE ports 1-8 native VLAN to VOICE + PoE power-cycle each AudioCodes to re-DHCP. Runbook: `docs/network/voice-vlan-cutover.md`; inventory: `docs/network/voice-phone-inventory.md`.
- **[PLANNED] Network logging / observability (spec written, build later).** Diagnosis 2026-06-18: the UniFi controller retains **ZERO** client events/alarms for Cascades (7-day pull) and pfSense logs roll over in hours -> device drops/kicks/deauths are not captured, so the network is a black box after the fact. Plan: **Synology cascadesDS (DSM Log Center syslog server) as the on-site collector** (NOT CS-SERVER -- fragile EOL DC), with pfSense + UniFi-controller + AP syslog as sources and a 1-2 min `/stat/sta` client snapshotter to fill the controller's history gap. Optional later: Container Manager Graylog/Loki + Discord alerting. Spec: `docs/network/network-logging-plan.md`. Next: confirm Synology model/RAM/DSM.
- **[PENDING] Wireless RF Phase 0 + Phase 1 (pending go-ahead + evening window):**
- Phase 0 (safe anytime): pfSense ping-check off for 240 DHCP pools, disable 3 AM AP firmware auto-upgrade, enable full pfSense logging (DHCP/DNS/firewall/system/gateway) with rotation.
- Phase 1 (windowed, per-zone, evening): combined per-AP radio_table PUT -- ng power medium (42 at-Low radios only, not the 24 disabled), na ht 40 (76 radios), na min_rssi -82 (69 at -77). Dry-run clean. Rollback auto-saved. Validate with watch-ap before/after.
@@ -516,6 +519,7 @@ Syncro live pull 2026-06-18: **0 open tickets.** No hours drawn from the 2026-06
| 2026-06-18 | **Power outage follow-ups: OpenVPN flapping root-caused; kitchen printer casualty resolved.** OpenVPN disconnect/reconnect cycle = configured `--inactive` idle timeout (~300s) on the pfSense server, not a fault. Fix proposed (raise/disable); not applied. Kitchen thermal printer (iPad POS) would not print post-outage -- booted during DHCP-down window, cached disconnected state; fixed by power-cycle. DHCP straggler sweep: 13/13 active senders completing, 0 stuck. |
| 2026-06-18 | **Synology Drive sync architecture diagnosed; Team Folder migration plan produced.** Current Drive sync is Sync-user My Drive only (not the real shared folders). Real NAS shares (Server 1.9 G, Management 5.5 G, Public ~50 G, SalesDept ~23 G) are not mirrored. Plan: Team Folder Download-only tasks into `D:\Shares\_SynMigration\<share>` staging; pilot on `/volume1/Server`. No changes made. |
| 2026-06-18 | **DESKTOP-TRCIEJA (Lupe Sanchez) performance diagnosed; replace-not-remediate decision.** Root causes: (a) EOL hardware -- Gateway ZX6971 AIO, Intel i3-2120 (2011, 2C/4T), 8 GB RAM, Win11 unsupported; (b) dual real-time AV -- ACG Bitdefender (keep) + leftover Datto stack (Datto RMM/CentraStage + Datto EDR/Infocyte + bundled DattoAV) both scanning every file on a 2-core CPU under memory pressure. OneDrive ruled out (desktop is local). Howard decided: no remediation; order replacement. Another instance of the fleet-wide leftover-Datto-stack cleanup. |
| 2026-06-18 | **Voice VLAN 30: all 22 Poly phones migrated; network-logging spec written.** Completed the Poly cutover live -- all 22 WiFi phones re-keyed to the voice PPSK onto `10.0.30.202-.223` (per-phone location inventory in `docs/network/voice-phone-inventory.md`); first phone (Lauren Hasselman) dial-tone + outbound call verified. Vertical desktop fixed via port-16 bounce (controller API + CSRF) -> `10.0.30.201`. AudioCodes (8, wired) still pending (flip + PoE power-cycle). Separately, found the UniFi controller retains **ZERO** client events for Cascades (drop/kick history not captured) -> wrote a network-logging spec (`docs/network/network-logging-plan.md`): Synology Log Center on-site collector, pfSense+UniFi syslog sources, client snapshotter. Plan only -- build later. |
---

View File

@@ -18,7 +18,7 @@ Run `/wiki-lint` to check for stale entries and broken backlinks.
| Article | Summary | Last Compiled |
|---|---|---|
| [Cascades of Tucson](clients/cascades-tucson.md) | Prepaid block $175/hr, **55.75 hrs remaining** (live 2026-06-18); senior living; active domain migration + HIPAA compliance project; single DC on aging R610 hardware; caregiver restricted-access model PROVEN 2026-06-05: Hybrid Entra Join + CA allow-list + ALIS SSO validated on NURSESTATION-PC/pilot.test; GPO `CSC - Caregiver Workstation` (shortcuts + printers) built + validated; GPO `CSC - Caregiver Device Lockdown` deployed (HIPAA auto-logoff, activates on reboot); INTUNE_A PendingInput tenant-wide (MS case open; GPO path used instead); folder-redirection root cause fixed 2026-06-08 (fdeploy.ini); shared mailboxes grievances@/Surveys@ created + delegated 2026-06-12 (#32417); Monday cutover to real caregivers pending; #32383 (bill.com/BOK chris.knight) Resolved; UniFi wifi RF (77 U7-Pro APs/~587 clients via UOS controller): 2.4GHz over-coverage = primary pain; pfSense ruled out as cause; Floor-4 power-down pilot applied 2026-06-16 (retry 13.2->9.5%); coverage-thin disable plan + 2.4 remediation runbook staged; DFS empirically clean; 6GHz untapped; CS-SERVER OS RAID-1 degraded 2026-06-15 (data-loss risk; cloud backup now started); Voice VLAN (VLAN 30) consolidation planned 2026-06-16 for Vertical phones + remote desktop (CSCNet confirmed a shared PPSK SSID); KPI dashboard for Ashley Jensen scoped 2026-06-17 (Power BI + SharePoint phased plan, parked); Voice VLAN 30 built + 22/22 Poly cut over 2026-06-17 (AudioCodes 0/8 pending); building power outage 2026-06-17 (pfSense on UPS surge-only side) full site down + recovered; DESKTOP-TRCIEJA (Lupe Sanchez) slow Excel diagnosed 2026-06-18 = EOL i3-2120 hardware + dual real-time AV (leftover Datto stack) -> replace machine; Syncro 0 open tickets | 2026-06-18 |
| [Cascades of Tucson](clients/cascades-tucson.md) | Prepaid block $175/hr, **55.75 hrs remaining** (live 2026-06-18); senior living; active domain migration + HIPAA compliance project; single DC on aging R610 hardware; caregiver restricted-access model PROVEN 2026-06-05: Hybrid Entra Join + CA allow-list + ALIS SSO validated on NURSESTATION-PC/pilot.test; GPO `CSC - Caregiver Workstation` (shortcuts + printers) built + validated; GPO `CSC - Caregiver Device Lockdown` deployed (HIPAA auto-logoff, activates on reboot); INTUNE_A PendingInput tenant-wide (MS case open; GPO path used instead); folder-redirection root cause fixed 2026-06-08 (fdeploy.ini); shared mailboxes grievances@/Surveys@ created + delegated 2026-06-12 (#32417); Monday cutover to real caregivers pending; #32383 (bill.com/BOK chris.knight) Resolved; UniFi wifi RF (77 U7-Pro APs/~587 clients via UOS controller): 2.4GHz over-coverage = primary pain; pfSense ruled out as cause; Floor-4 power-down pilot applied 2026-06-16 (retry 13.2->9.5%); coverage-thin disable plan + 2.4 remediation runbook staged; DFS empirically clean; 6GHz untapped; CS-SERVER OS RAID-1 degraded 2026-06-15 (data-loss risk; cloud backup now started); Voice VLAN (VLAN 30) consolidation planned 2026-06-16 for Vertical phones + remote desktop (CSCNet confirmed a shared PPSK SSID); KPI dashboard for Ashley Jensen scoped 2026-06-17 (Power BI + SharePoint phased plan, parked); Voice VLAN 30 built + 22/22 Poly cut over 2026-06-17 (AudioCodes 0/8 pending); building power outage 2026-06-17 (pfSense on UPS surge-only side) full site down + recovered; DESKTOP-TRCIEJA (Lupe Sanchez) slow Excel diagnosed 2026-06-18 = EOL i3-2120 hardware + dual real-time AV (leftover Datto stack) -> replace machine; network-logging spec written 2026-06-18 (on-site Synology Log Center; UniFi retains 0 client events -- drop/kick history not captured); Syncro 0 open tickets | 2026-06-18 |
| [Dataforth Corporation](clients/dataforth.md) | Prepaid block ~$2,099/mo, 34.5 hrs remaining; signal conditioning manufacturer; 64 DOS test stations; 2025 crypto attack recovery + incomplete restore (files dropped across shares — migration-gap audit in progress); 2026-03-27 phishing incident + MFA rollout; active test datasheet pipeline project; Neptune Exchange colocated at D2; 2026-06-04 SP1366 file recovery (19/20 PDFs restored from HGHAUBNER pre-attack backup); GuruRMM fleet 13→45 agents; 2026-06-02 Syncro asset reconciliation (78→20 keep/21 flag/28 remove/9 verify); fleet-wide Syncro agent break ~2025-10-06; Bitdefender phase-off in progress | 2026-06-04 |
| [Instrumental Music Center](clients/instrumental-music-center.md) | Prepaid block $175/hr, 12.5 hrs remaining; music retail/repair; AIMsi POS on SQL Server 2019; phantom DC causing slow logons; GuruRMM enrolled (IMC1) | 2026-05-24 |
| [Valley Wide Plastering](clients/valleywide.md) | Prepaid block, 10 hrs remaining; plastering/stucco contractor; HP DL360 Gen10 + XenServer; VB6 app modernization project; RDWeb brute-force incident; 11 Yealink phones pending | 2026-06-14 |