@rubytech/create-maxy-code 0.1.478 → 0.1.479

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1789-reseat-channel-row-fork.md +1244 -0
  3. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1818-loop-gate-app-routes.md +462 -0
  4. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1819-top-level-label-allowlist.md +321 -0
  5. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1831-data-portal-class-derived-allowlist.md +2177 -0
  6. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1789-reseat-channel-row-fork-design.md +201 -0
  7. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1818-loop-gate-app-routes-design.md +217 -0
  8. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1819-top-level-label-allowlist-design.md +80 -0
  9. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1831-data-portal-class-derived-allowlist-design.md +163 -0
  10. package/payload/platform/lib/graph-style/dist/index.d.ts.map +1 -1
  11. package/payload/platform/lib/graph-style/dist/index.js +22 -0
  12. package/payload/platform/lib/graph-style/dist/index.js.map +1 -1
  13. package/payload/platform/lib/graph-style/src/__tests__/parity.test.ts +6 -0
  14. package/payload/platform/lib/graph-style/src/index.ts +20 -0
  15. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +13 -1
  16. package/payload/platform/plugins/cloudflare/PLUGIN.md +26 -1
  17. package/payload/platform/plugins/cloudflare/bin/d1-http.mjs +80 -0
  18. package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +17 -2
  19. package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +357 -0
  20. package/payload/platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs +103 -0
  21. package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +2 -2
  22. package/payload/platform/plugins/cloudflare/mcp/__tests__/d1-http.test.ts +96 -0
  23. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +120 -0
  24. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +59 -4
  25. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +176 -0
  26. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-state-contract.test.ts +75 -0
  27. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +131 -0
  28. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-sign.test.ts +68 -0
  29. package/payload/platform/plugins/cloudflare/mcp/__tests__/schema-exposed-dirs.test.ts +88 -0
  30. package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +39 -0
  31. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +68 -1
  32. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +30 -1
  33. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/portal-sign.mjs +87 -0
  34. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +8 -4
  35. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts +10 -0
  36. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +107 -1
  37. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +82 -2
  38. package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +9 -0
  39. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.css +56 -0
  40. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +98 -2
  41. package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +12 -0
  42. package/payload/platform/plugins/docs/references/cloudflare.md +12 -0
  43. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.d.ts +2 -0
  44. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.d.ts.map +1 -0
  45. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.js +66 -0
  46. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.js.map +1 -0
  47. package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.d.ts +41 -0
  48. package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.d.ts.map +1 -0
  49. package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.js +75 -0
  50. package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.js.map +1 -0
  51. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +122 -0
  52. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
  53. package/payload/server/public/activity.html +8 -6
  54. package/payload/server/public/assets/{AdminLoginScreens-DR5eLF-a.js → AdminLoginScreens-DkxQZnvB.js} +1 -1
  55. package/payload/server/public/assets/AdminShell-Cv_nIFGe.js +2 -0
  56. package/payload/server/public/assets/Checkbox-CsTWK8mm.js +1 -0
  57. package/payload/server/public/assets/activity-BbnTiVBT.js +1 -0
  58. package/payload/server/public/assets/{admin-D2KLZnQW.js → admin-BZY3i_Ff.js} +1 -1
  59. package/payload/server/public/assets/{browser-CDWqEiCD.js → browser-BbIiSFKr.js} +1 -1
  60. package/payload/server/public/assets/{calendar-BG7fOpbB.js → calendar-BvxeTHvQ.js} +1 -1
  61. package/payload/server/public/assets/chat-DUKxufRk.js +1 -0
  62. package/payload/server/public/assets/chevron-left-BtfJD6LL.js +1 -0
  63. package/payload/server/public/assets/data-hl1wa-Ax.js +1 -0
  64. package/payload/server/public/assets/{graph-DRloNic4.js → graph-CDSGRoeb.js} +1 -1
  65. package/payload/server/public/assets/graph-labels-C5qLwnWA.js +1 -0
  66. package/payload/server/public/assets/{maximize-2-CzDaBugs.js → maximize-2-CAzH8dED.js} +1 -1
  67. package/payload/server/public/assets/{operator-BZeeyait.js → operator-BK7r8DAB.js} +1 -1
  68. package/payload/server/public/assets/{page-ef-6rRPL.js → page-B5YngOrK.js} +1 -1
  69. package/payload/server/public/assets/{page-B0obcg3b.js → page-BsCS0Dmc.js} +1 -1
  70. package/payload/server/public/assets/{public-9D3MMOkC.js → public-C1Pz-IBs.js} +1 -1
  71. package/payload/server/public/assets/{rotate-ccw-DXEKUhXP.js → rotate-ccw-UDln4Tou.js} +1 -1
  72. package/payload/server/public/assets/{tasks-BB0L7e5a.js → tasks-B-cLsn3p.js} +1 -1
  73. package/payload/server/public/assets/{time-entry-format-CsYAjplS.js → time-entry-format-DPOBIE18.js} +1 -1
  74. package/payload/server/public/assets/{triangle-alert-0lSBIUg-.js → triangle-alert-Dz-CZKo5.js} +1 -1
  75. package/payload/server/public/assets/{useCopyFeedback-jTBQ2h0V.js → useCopyFeedback-wtUHGohk.js} +1 -1
  76. package/payload/server/public/assets/{useSelectionMode-BooArySZ.js → useSelectionMode-sph7AQT5.js} +1 -1
  77. package/payload/server/public/assets/useSubAccountSwitcher-BRIluIKf.css +1 -0
  78. package/payload/server/public/assets/{useVoiceRecorder-PUT8q_sm.js → useVoiceRecorder-DN4jYQ0A.js} +1 -1
  79. package/payload/server/public/brand-defaults.css +1 -0
  80. package/payload/server/public/browser.html +5 -5
  81. package/payload/server/public/calendar.html +6 -6
  82. package/payload/server/public/chat.html +12 -12
  83. package/payload/server/public/data.html +11 -11
  84. package/payload/server/public/graph.html +11 -11
  85. package/payload/server/public/index.html +14 -14
  86. package/payload/server/public/operator.html +14 -14
  87. package/payload/server/public/privacy.html +1 -0
  88. package/payload/server/public/public.html +12 -12
  89. package/payload/server/public/tasks.html +6 -5
  90. package/payload/server/public/vnc-popout.html +1 -0
  91. package/payload/server/server.js +741 -366
  92. package/payload/server/public/assets/AdminShell-D9zaUxPd.js +0 -2
  93. package/payload/server/public/assets/Checkbox-DcbbAIe5.js +0 -1
  94. package/payload/server/public/assets/activity-C9P5NjEZ.js +0 -1
  95. package/payload/server/public/assets/chat-RyH-WYQC.js +0 -1
  96. package/payload/server/public/assets/chevron-left-DmXCr6nB.js +0 -1
  97. package/payload/server/public/assets/data-BaY_LkLR.js +0 -1
  98. package/payload/server/public/assets/graph-labels-DD-GqS4v.js +0 -1
  99. package/payload/server/public/assets/useSubAccountSwitcher-D9knHOKq.css +0 -1
  100. /package/payload/server/public/assets/{useSubAccountSwitcher-BZnzc5C3.js → useSubAccountSwitcher-v8LyBzZN.js} +0 -0
@@ -122,3 +122,59 @@ body {
122
122
  color: var(--dp-muted);
123
123
  padding: 0.75rem 0;
124
124
  }
125
+
126
+ /* "Shared with you" — the folder tree the device publishes. Deliberately reuses
127
+ the existing variables and adds none: portal-brand-css.mjs rewrites the one
128
+ :root block from the brand palette and validates every value it finds there,
129
+ so a new variable declared here would either be dropped or fail that check. */
130
+ .dp-shared {
131
+ margin-top: 28px;
132
+ border-top: 1px solid var(--dp-rule);
133
+ padding-top: 16px;
134
+ }
135
+
136
+ .dp-subtitle {
137
+ font-size: 15px;
138
+ font-weight: 600;
139
+ margin: 0 0 8px;
140
+ color: var(--dp-ink);
141
+ }
142
+
143
+ .dp-crumbs {
144
+ font-size: 13px;
145
+ color: var(--dp-muted);
146
+ margin-bottom: 10px;
147
+ }
148
+
149
+ /* Crumbs and folder rows are buttons (navigation is a fetch, not a page load),
150
+ so they need the button chrome stripped to sit beside the upload list's
151
+ anchors without looking like form controls. */
152
+ .dp-crumb,
153
+ .dp-folder-name {
154
+ background: none;
155
+ border: 0;
156
+ padding: 0;
157
+ font: inherit;
158
+ cursor: pointer;
159
+ color: var(--dp-accent);
160
+ }
161
+
162
+ .dp-crumb:hover,
163
+ .dp-folder-name:hover {
164
+ text-decoration: underline;
165
+ }
166
+
167
+ .dp-folder-name {
168
+ font-weight: 600;
169
+ }
170
+
171
+ /* The file rows inside the tree are buttons too — same reset, but they must
172
+ match .dp-file-name's weight so the two lists read as one surface. */
173
+ .dp-shared .dp-file-name {
174
+ background: none;
175
+ border: 0;
176
+ padding: 0;
177
+ font: inherit;
178
+ cursor: pointer;
179
+ text-align: left;
180
+ }
@@ -16,6 +16,12 @@
16
16
  var loginEl = document.getElementById('dp-login');
17
17
  var panelEl = document.getElementById('dp-panel');
18
18
  var filesEl = document.getElementById('dp-files');
19
+ var sharedEl = document.getElementById('dp-shared');
20
+ var crumbsEl = document.getElementById('dp-crumbs');
21
+ var entriesEl = document.getElementById('dp-entries');
22
+
23
+ /* The folder currently open in the "shared with you" tree; '' is its root. */
24
+ var prefix = '';
19
25
 
20
26
  function say(msg) {
21
27
  statusEl.textContent = msg;
@@ -64,13 +70,103 @@
64
70
  });
65
71
  }
66
72
 
73
+ /* One crumb. A button rather than a link: navigating the tree is a fetch, not
74
+ a page load, and the session cookie stays where it is. */
75
+ function crumb(label, target) {
76
+ var b = document.createElement('button');
77
+ b.className = 'dp-crumb';
78
+ b.type = 'button';
79
+ b.textContent = label;
80
+ b.addEventListener('click', function () {
81
+ prefix = target;
82
+ refresh();
83
+ });
84
+ return b;
85
+ }
86
+
87
+ function renderCrumbs() {
88
+ crumbsEl.replaceChildren();
89
+ crumbsEl.appendChild(crumb('All folders', ''));
90
+ var parts = prefix ? prefix.split('/') : [];
91
+ parts.forEach(function (part, i) {
92
+ crumbsEl.appendChild(document.createTextNode(' / '));
93
+ crumbsEl.appendChild(crumb(part, parts.slice(0, i + 1).join('/')));
94
+ });
95
+ }
96
+
97
+ /* Every label goes through textContent, same rule as the upload list: these
98
+ names come off the device and are never trusted as markup. */
99
+ function renderEntries(entries) {
100
+ renderCrumbs();
101
+ entriesEl.replaceChildren();
102
+ if (!entries.length) {
103
+ entriesEl.appendChild(item('Nothing here.'));
104
+ return;
105
+ }
106
+ entries.forEach(function (e) {
107
+ var li = document.createElement('li');
108
+ li.className = 'dp-file-row';
109
+
110
+ var b = document.createElement('button');
111
+ b.className = e.kind === 'directory' ? 'dp-folder-name' : 'dp-file-name';
112
+ b.type = 'button';
113
+ b.textContent = e.kind === 'directory' ? e.name + '/' : e.name;
114
+ b.addEventListener('click', function () {
115
+ if (e.kind === 'directory') {
116
+ prefix = e.relPath;
117
+ refresh();
118
+ } else {
119
+ openShared(e.relPath);
120
+ }
121
+ });
122
+ li.appendChild(b);
123
+ entriesEl.appendChild(li);
124
+ });
125
+ }
126
+
127
+ /* These files live on the device, not in the portal's own store, so the
128
+ portal checks the device is reachable and then hands back a link that
129
+ expires. A device that is down says so plainly instead of failing as a
130
+ broken download. */
131
+ function openShared(relPath) {
132
+ say('Opening…');
133
+ api('/api/download?path=' + encodeURIComponent(relPath))
134
+ .then(function (r) {
135
+ return r.json().then(function (d) {
136
+ return { s: r.status, d: d };
137
+ });
138
+ })
139
+ .then(function (res) {
140
+ if (res.s === 503) {
141
+ say('That file is on the device, which is not reachable right now. Try again shortly.');
142
+ return;
143
+ }
144
+ if (!res.d.ok || !res.d.url) {
145
+ say('That file could not be opened.');
146
+ return;
147
+ }
148
+ say('');
149
+ window.location.assign(res.d.url);
150
+ })
151
+ .catch(function () {
152
+ say('That file could not be opened.');
153
+ });
154
+ }
155
+
67
156
  function refresh() {
68
- return api('/api/files')
157
+ return api('/api/files?prefix=' + encodeURIComponent(prefix))
69
158
  .then(function (r) {
70
159
  return r.json();
71
160
  })
72
161
  .then(function (d) {
73
- if (d.ok) render(d.files || []);
162
+ if (!d.ok) return;
163
+ render(d.files || []);
164
+ var entries = d.entries || [];
165
+ // Hidden at the root only: once inside a folder the section stays put,
166
+ // so an empty folder reads as empty rather than as the whole tree
167
+ // vanishing.
168
+ sharedEl.hidden = prefix === '' && entries.length === 0;
169
+ renderEntries(entries);
74
170
  })
75
171
  .catch(function () {
76
172
  say('Could not load your files.');
@@ -4,6 +4,7 @@
4
4
  # __D1_DATABASE_NAME__ the D1 database name (<accountId>-<purpose>)
5
5
  # __D1_DATABASE_ID__ the id printed by `wrangler d1 create`
6
6
  # __R2_BUCKET_NAME__ the R2 bucket name (<accountId>-<purpose>)
7
+ # __PORTAL_INSTALL_ORIGIN__ the install's public origin (https://<tunnel-hostname>)
7
8
  name = "__PROJECT_NAME__"
8
9
  pages_build_output_dir = "."
9
10
  compatibility_date = "2024-01-01"
@@ -19,3 +20,14 @@ database_id = "__D1_DATABASE_ID__"
19
20
  [[r2_buckets]]
20
21
  binding = "BUCKET"
21
22
  bucket_name = "__R2_BUCKET_NAME__"
23
+
24
+ # Where the install answers, so a download of a device-held file can be signed
25
+ # and sent there. Public information — it is the hostname a client's browser
26
+ # ends up at.
27
+ #
28
+ # Its partner, PORTAL_FETCH_SECRET, is deliberately NOT here. This file is
29
+ # committed to the account's assembled tree and the deploy publishes the
30
+ # directory it is handed, so a secret in it is a secret in a repo and one deploy
31
+ # from being served. Set it with `wrangler pages secret put`.
32
+ [vars]
33
+ PORTAL_INSTALL_ORIGIN = "__PORTAL_INSTALL_ORIGIN__"
@@ -101,6 +101,18 @@ The most common cause is wrong nameservers on the domain. The domain must use Cl
101
101
  - The `/data` page handles this itself: files over 95 MB are split into ~48 MB parts and sent one at a time under the cap, and the server reassembles them. No plan change or dashboard action is needed for uploads up to the 2 GB app ceiling.
102
102
  - Raising the proxied body limit is a paid-plan change made on the Cloudflare dashboard, not something the agent does.
103
103
 
104
+ ## The client file portal
105
+
106
+ A portal gives named people their own passcode-protected page on a custom domain. Two things sit on it.
107
+
108
+ **What they send you.** Anything they upload lands in the account's own storage, one area per person, and nobody sees anyone else's files.
109
+
110
+ **What you have prepared for them.** The same page shows the account's deliverable folders — the work produced for that client. Which folders those are is not a setting to maintain: it follows the account's own filing structure, so a business whose work is organised into quotes sees quotes, and a business organised some other way sees that instead. Operator working data, the client's own uploads, and anything internal are never shown.
111
+
112
+ Folder names and sizes are published to the portal on a schedule, so the client can browse even while the device is off. The files themselves stay on the device. Opening one checks the device is reachable and then hands the browser a link that expires after a few minutes; if the device is down the client is told exactly that, rather than getting a broken download.
113
+
114
+ Each person is bound to one account when they are enrolled. Someone enrolled before this existed has no account bound and sees no folders until they are enrolled again.
115
+
104
116
  ## What the agent does and does not do
105
117
 
106
118
  **Does:** invokes `cloudflared` directly via Bash, following `plugins/cloudflare/references/manual-setup.md` step by step; quotes click-paths from the reference files verbatim; verifies external reachability with `curl -I` and surfaces the response.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=portal-index-audit.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"portal-index-audit.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/portal-index-audit.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { auditPortalIndex, formatPortalIndexAudit, STALE_AFTER_INTERVALS, } from "../lib/portal-index-audit.js";
3
+ const HOUR = 3_600_000;
4
+ const NOW = 1_800_000_000_000;
5
+ const fresh = (ms) => ({
6
+ "acc-a": { lastPushMs: NOW - ms, rows: 41, exposed: ["output", "quotes"] },
7
+ });
8
+ describe("auditPortalIndex", () => {
9
+ it("reports a recently pushed account as healthy", () => {
10
+ const r = auditPortalIndex(["acc-a"], fresh(HOUR - 1), NOW, HOUR);
11
+ expect(r.stale).toBe(0);
12
+ expect(r.neverRan).toBe(0);
13
+ expect(r.accounts[0].ageMin).toBe(59);
14
+ expect(r.accounts[0].rows).toBe(41);
15
+ });
16
+ it("tolerates one missed cycle, flags two", () => {
17
+ // One miss is ordinary (a slow push, a device asleep at the tick). The
18
+ // threshold has to be loose enough that firing means something.
19
+ expect(auditPortalIndex(["acc-a"], fresh(HOUR * 1.5), NOW, HOUR).stale).toBe(0);
20
+ expect(auditPortalIndex(["acc-a"], fresh(HOUR * STALE_AFTER_INTERVALS + 1), NOW, HOUR).stale).toBe(1);
21
+ });
22
+ // The signature of the failure this module exists for: the spawn never fires,
23
+ // so no state is ever written and no other line is ever emitted.
24
+ it("reports an enrolled account with no state as never-ran, not as stale", () => {
25
+ const r = auditPortalIndex(["acc-a"], {}, NOW, HOUR);
26
+ expect(r.neverRan).toBe(1);
27
+ expect(r.stale).toBe(0);
28
+ expect(r.accounts[0].ageMin).toBeNull();
29
+ });
30
+ it("ignores accounts that have no portal", () => {
31
+ const r = auditPortalIndex([], fresh(HOUR * 99), NOW, HOUR);
32
+ expect(r.accounts).toEqual([]);
33
+ expect(r.stale).toBe(0);
34
+ });
35
+ it("survives a malformed state entry rather than throwing", () => {
36
+ const bad = { "acc-a": { rows: 1, exposed: [] } };
37
+ expect(auditPortalIndex(["acc-a"], bad, NOW, HOUR).neverRan).toBe(1);
38
+ });
39
+ it("never reports a negative age when a clock moves backwards", () => {
40
+ const r = auditPortalIndex(["acc-a"], fresh(-HOUR), NOW, HOUR);
41
+ expect(r.accounts[0].ageMin).toBe(0);
42
+ expect(r.accounts[0].stale).toBe(false);
43
+ });
44
+ });
45
+ describe("formatPortalIndexAudit", () => {
46
+ it("emits a summary line even when everything is healthy", () => {
47
+ // An audit that goes quiet on success is indistinguishable from an audit
48
+ // that stopped running — the exact defect this module closes.
49
+ const lines = formatPortalIndexAudit(auditPortalIndex(["acc-a"], fresh(60_000), NOW, HOUR));
50
+ expect(lines).toHaveLength(1);
51
+ expect(lines[0]).toBe("[portal-index] op=audit accounts=1 stale=0 never-ran=0");
52
+ });
53
+ it("names a stale account with its age and exposed set", () => {
54
+ const lines = formatPortalIndexAudit(auditPortalIndex(["acc-a"], fresh(HOUR * 3), NOW, HOUR));
55
+ expect(lines[0]).toContain("account=acc-a");
56
+ expect(lines[0]).toContain("ageMin=180");
57
+ expect(lines[0]).toContain("exposed=output,quotes");
58
+ expect(lines[0]).toContain("result=stale");
59
+ });
60
+ it("distinguishes never-ran from stale in the line itself", () => {
61
+ const lines = formatPortalIndexAudit(auditPortalIndex(["acc-a"], {}, NOW, HOUR));
62
+ expect(lines[0]).toContain("result=never-ran");
63
+ expect(lines[0]).toContain("rows=0");
64
+ });
65
+ });
66
+ //# sourceMappingURL=portal-index-audit.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"portal-index-audit.test.js","sourceRoot":"","sources":["../../src/__tests__/portal-index-audit.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,41 @@
1
+ export interface PortalIndexEntry {
2
+ lastPushMs: number;
3
+ rows: number;
4
+ exposed: string[];
5
+ }
6
+ export type PortalIndexState = Record<string, PortalIndexEntry>;
7
+ export interface PortalAccountAudit {
8
+ accountId: string;
9
+ /** No state entry at all: enrolled for a portal, never successfully pushed. */
10
+ neverRan: boolean;
11
+ /** Whole minutes since the last verified push; null when it never ran. */
12
+ ageMin: number | null;
13
+ rows: number | null;
14
+ exposed: string[];
15
+ stale: boolean;
16
+ }
17
+ export interface PortalIndexAuditResult {
18
+ accounts: PortalAccountAudit[];
19
+ stale: number;
20
+ neverRan: number;
21
+ }
22
+ /**
23
+ * Two intervals, not one: a single missed cycle is ordinary (a slow push, a
24
+ * device asleep at the tick). Two consecutive misses is a pattern, and the
25
+ * threshold has to be loose enough that the line means something when it fires.
26
+ */
27
+ export declare const STALE_AFTER_INTERVALS = 2;
28
+ /**
29
+ * @param portalAccountIds accounts carrying a data-portal.json — the enrolment
30
+ * signal. An account absent from this list has no portal and is not audited.
31
+ * @param state the push's own success record, keyed by accountId.
32
+ * @param nowMs
33
+ * @param intervalMs the push's cadence.
34
+ */
35
+ export declare function auditPortalIndex(portalAccountIds: string[], state: PortalIndexState, nowMs: number, intervalMs: number): PortalIndexAuditResult;
36
+ /** One line per account needing attention, plus one summary line that fires
37
+ * even when everything is healthy — an audit that goes quiet when it finds
38
+ * nothing is indistinguishable from an audit that stopped running, which is
39
+ * the exact defect this whole module exists to close. */
40
+ export declare function formatPortalIndexAudit(result: PortalIndexAuditResult): string[];
41
+ //# sourceMappingURL=portal-index-audit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"portal-index-audit.d.ts","sourceRoot":"","sources":["../../src/lib/portal-index-audit.ts"],"names":[],"mappings":"AAkBA,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAA;IAClB,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,MAAM,EAAE,CAAA;CAClB;AAED,MAAM,MAAM,gBAAgB,GAAG,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAA;AAE/D,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,MAAM,CAAA;IACjB,+EAA+E;IAC/E,QAAQ,EAAE,OAAO,CAAA;IACjB,0EAA0E;IAC1E,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,OAAO,EAAE,MAAM,EAAE,CAAA;IACjB,KAAK,EAAE,OAAO,CAAA;CACf;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,EAAE,kBAAkB,EAAE,CAAA;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,MAAM,CAAA;CACjB;AAED;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,IAAI,CAAA;AAEtC;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC9B,gBAAgB,EAAE,MAAM,EAAE,EAC1B,KAAK,EAAE,gBAAgB,EACvB,KAAK,EAAE,MAAM,EACb,UAAU,EAAE,MAAM,GACjB,sBAAsB,CA0BxB;AAED;;;0DAG0D;AAC1D,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,sBAAsB,GAAG,MAAM,EAAE,CAgB/E"}
@@ -0,0 +1,75 @@
1
+ // Standing check: is each portal-serving account's folder index still being
2
+ // refreshed?
3
+ //
4
+ // Every other signal in the index push is event-driven — the script logs when
5
+ // it runs. So the one failure it cannot report is its own absence: if the
6
+ // hourly spawn never fires, there is no line at all, the client's folder tree
7
+ // silently freezes at whatever it last held, and silence is indistinguishable
8
+ // from a clean run.
9
+ //
10
+ // This audit therefore must NOT depend on the thing it audits. It runs
11
+ // in-process inside the heartbeat rather than as a spawned child, and it reads
12
+ // only local files — the state the push writes on success, and the presence of
13
+ // a data-portal.json. No D1, no Cloudflare credential, no second spawn that
14
+ // could fail the same way.
15
+ //
16
+ // The push writes state ONLY after a verified write, so a stale entry means the
17
+ // push stopped succeeding, not merely that it stopped logging.
18
+ /**
19
+ * Two intervals, not one: a single missed cycle is ordinary (a slow push, a
20
+ * device asleep at the tick). Two consecutive misses is a pattern, and the
21
+ * threshold has to be loose enough that the line means something when it fires.
22
+ */
23
+ export const STALE_AFTER_INTERVALS = 2;
24
+ /**
25
+ * @param portalAccountIds accounts carrying a data-portal.json — the enrolment
26
+ * signal. An account absent from this list has no portal and is not audited.
27
+ * @param state the push's own success record, keyed by accountId.
28
+ * @param nowMs
29
+ * @param intervalMs the push's cadence.
30
+ */
31
+ export function auditPortalIndex(portalAccountIds, state, nowMs, intervalMs) {
32
+ const threshold = intervalMs * STALE_AFTER_INTERVALS;
33
+ const accounts = portalAccountIds.map((accountId) => {
34
+ const entry = state[accountId];
35
+ if (!entry || typeof entry.lastPushMs !== "number") {
36
+ // Never-ran is reported as its own condition rather than as infinite
37
+ // staleness: the remedies differ (a first run vs a broken cycle), and a
38
+ // brand-new portal must not look like a regression on its first tick.
39
+ return { accountId, neverRan: true, ageMin: null, rows: null, exposed: [], stale: false };
40
+ }
41
+ const ageMs = Math.max(0, nowMs - entry.lastPushMs);
42
+ return {
43
+ accountId,
44
+ neverRan: false,
45
+ ageMin: Math.floor(ageMs / 60_000),
46
+ rows: typeof entry.rows === "number" ? entry.rows : null,
47
+ exposed: Array.isArray(entry.exposed) ? entry.exposed : [],
48
+ stale: ageMs > threshold,
49
+ };
50
+ });
51
+ return {
52
+ accounts,
53
+ stale: accounts.filter((a) => a.stale).length,
54
+ neverRan: accounts.filter((a) => a.neverRan).length,
55
+ };
56
+ }
57
+ /** One line per account needing attention, plus one summary line that fires
58
+ * even when everything is healthy — an audit that goes quiet when it finds
59
+ * nothing is indistinguishable from an audit that stopped running, which is
60
+ * the exact defect this whole module exists to close. */
61
+ export function formatPortalIndexAudit(result) {
62
+ const lines = [];
63
+ for (const a of result.accounts) {
64
+ if (a.neverRan) {
65
+ lines.push(`[portal-index] op=audit account=${a.accountId} rows=0 result=never-ran`);
66
+ }
67
+ else if (a.stale) {
68
+ lines.push(`[portal-index] op=audit account=${a.accountId} rows=${a.rows} ageMin=${a.ageMin} ` +
69
+ `exposed=${a.exposed.join(",") || "none"} result=stale`);
70
+ }
71
+ }
72
+ lines.push(`[portal-index] op=audit accounts=${result.accounts.length} stale=${result.stale} never-ran=${result.neverRan}`);
73
+ return lines;
74
+ }
75
+ //# sourceMappingURL=portal-index-audit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"portal-index-audit.js","sourceRoot":"","sources":["../../src/lib/portal-index-audit.ts"],"names":[],"mappings":"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"}
@@ -28,6 +28,7 @@ import cronParser from "cron-parser";
28
28
  const { parseExpression } = cronParser;
29
29
  import { chooseDispatchMode, dispatchAgentTurn, runStandingAudit, auditSelfCancels } from "./agent-turn-dispatch.js";
30
30
  import { spawn } from "node:child_process";
31
+ import { auditPortalIndex, formatPortalIndexAudit, } from "../lib/portal-index-audit.js";
31
32
  import { shouldRunSentSweep, SENT_SWEEP_INTERVAL_MS, } from "../lib/sent-sweep-throttle.js";
32
33
  // ---------------------------------------------------------------------------
33
34
  // Brand-aware config directory — reads configDir from brand.json stamped at install time.
@@ -492,6 +493,110 @@ function maybeRunSentSweep(platformRoot) {
492
493
  });
493
494
  child.unref();
494
495
  }
496
+ const PORTAL_INDEX_STATE_FILE = resolve(CONFIG_DIR, "portal-index-push-state.json");
497
+ const PORTAL_INDEX_INTERVAL_MS = 60 * 60 * 1000;
498
+ /**
499
+ * Publish each portal-serving account's deliverable folder index, at most hourly.
500
+ *
501
+ * Spawned rather than imported, for the same reason as the sent-copy sweep: the
502
+ * script lives in the cloudflare plugin and talks to Cloudflare's API, which does
503
+ * not belong inside the scheduling build.
504
+ *
505
+ * The script decides WHICH accounts have portals (a `data-portal.json` is the
506
+ * signal) and which folders each one exposes. Nothing about that is configured
507
+ * here, so this stays a spawn on a timer.
508
+ *
509
+ * State is written before the spawn, not after it completes: a push that hangs
510
+ * must not cause a fresh one to start behind it every minute.
511
+ */
512
+ function maybeRunPortalIndexPush(platformRoot) {
513
+ let prev;
514
+ try {
515
+ prev = JSON.parse(readFileSync(PORTAL_INDEX_STATE_FILE, "utf8"));
516
+ }
517
+ catch {
518
+ prev = undefined;
519
+ }
520
+ const now = Date.now();
521
+ if (prev?.lastRunMs && now - prev.lastRunMs < PORTAL_INDEX_INTERVAL_MS)
522
+ return;
523
+ const script = resolve(platformRoot, "plugins/cloudflare/bin/portal-index-push.mjs");
524
+ if (!existsSync(script)) {
525
+ console.error(`[portal-index] skipped: ${script} not found`);
526
+ return;
527
+ }
528
+ try {
529
+ writeFileSync(PORTAL_INDEX_STATE_FILE, JSON.stringify({ lastRunMs: now }));
530
+ }
531
+ catch (err) {
532
+ console.error(`[portal-index] state write failed: ${err instanceof Error ? err.message : String(err)}`);
533
+ return;
534
+ }
535
+ const child = spawn(process.execPath, [script, "--all-accounts"], {
536
+ env: { ...process.env, PLATFORM_ROOT: platformRoot },
537
+ stdio: ["ignore", "inherit", "inherit"],
538
+ detached: true,
539
+ });
540
+ child.unref();
541
+ }
542
+ const PORTAL_AUDIT_STATE_FILE = resolve(CONFIG_DIR, "portal-index-audit-state.json");
543
+ /**
544
+ * Standing check: is each portal-serving account's index still being refreshed?
545
+ *
546
+ * Runs IN-PROCESS, not as a spawn, and reads only local files. That is the
547
+ * whole point — every other signal in the push is event-driven, so the one
548
+ * failure none of them can report is the push not running at all. An audit that
549
+ * went through the same spawn, or needed the same Cloudflare credential, would
550
+ * be blind to exactly the case it exists for.
551
+ *
552
+ * Throttled to the push's own cadence so the summary line stays readable in a
553
+ * log that already carries a line a minute.
554
+ */
555
+ function auditPortalIndexPush(platformRoot) {
556
+ let prev;
557
+ try {
558
+ prev = JSON.parse(readFileSync(PORTAL_AUDIT_STATE_FILE, "utf8"));
559
+ }
560
+ catch {
561
+ prev = undefined;
562
+ }
563
+ const now = Date.now();
564
+ if (prev?.lastRunMs && now - prev.lastRunMs < PORTAL_INDEX_INTERVAL_MS)
565
+ return;
566
+ const dataRoot = resolve(platformRoot, "..", "data");
567
+ const accountsDir = join(dataRoot, "accounts");
568
+ if (!existsSync(accountsDir))
569
+ return;
570
+ // A data-portal.json is the enrolment signal — the same one the push uses, so
571
+ // the two cannot disagree about which accounts are in scope.
572
+ let portalAccountIds = [];
573
+ try {
574
+ portalAccountIds = readdirSync(accountsDir).filter((d) => existsSync(join(accountsDir, d, "data-portal.json")));
575
+ }
576
+ catch {
577
+ return;
578
+ }
579
+ if (portalAccountIds.length === 0)
580
+ return;
581
+ let state = {};
582
+ try {
583
+ state = JSON.parse(readFileSync(join(dataRoot, "portal-index-state.json"), "utf8"));
584
+ }
585
+ catch {
586
+ // Absent state with portals enrolled is itself the never-ran signal, so an
587
+ // empty object is the correct input here rather than a reason to bail.
588
+ state = {};
589
+ }
590
+ for (const line of formatPortalIndexAudit(auditPortalIndex(portalAccountIds, state, now, PORTAL_INDEX_INTERVAL_MS))) {
591
+ console.error(line);
592
+ }
593
+ try {
594
+ writeFileSync(PORTAL_AUDIT_STATE_FILE, JSON.stringify({ lastRunMs: now }));
595
+ }
596
+ catch (err) {
597
+ console.error(`[portal-index] op=audit-state-write-failed err="${err instanceof Error ? err.message : String(err)}"`);
598
+ }
599
+ }
495
600
  async function main() {
496
601
  if (!acquireLock()) {
497
602
  console.error("[check-due] another instance running, exiting");
@@ -672,6 +777,23 @@ async function main() {
672
777
  catch (err) {
673
778
  console.error(`[email-sent-sweep] spawn failed: ${err instanceof Error ? err.message : String(err)}`);
674
779
  }
780
+ // --- Data-portal folder index (hourly) ---
781
+ // Guarded on the same principle: an optional hourly publish must not cost
782
+ // the every-minute dispatcher its completion line.
783
+ try {
784
+ maybeRunPortalIndexPush(process.env.PLATFORM_ROOT);
785
+ }
786
+ catch (err) {
787
+ console.error(`[portal-index] spawn failed: ${err instanceof Error ? err.message : String(err)}`);
788
+ }
789
+ // Deliberately AFTER the spawn and independent of whether it succeeded:
790
+ // this is the check that speaks when the push does not.
791
+ try {
792
+ auditPortalIndexPush(process.env.PLATFORM_ROOT);
793
+ }
794
+ catch (err) {
795
+ console.error(`[portal-index] op=audit-failed err="${err instanceof Error ? err.message : String(err)}"`);
796
+ }
675
797
  console.error(`[check-due] done: ${processed} processed, ${dispatched} dispatched, ${errors} errors`);
676
798
  }
677
799
  finally {