@rubytech/create-maxy-code 0.1.562 → 0.1.563
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.
- package/package.json +1 -1
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +19 -11
- package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +6 -0
- package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +19 -5
- package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +6 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +77 -6
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-session-grant.test.ts +39 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visit-status-write.test.ts +176 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visits-scope.test.ts +112 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +23 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +94 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +31 -4
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visit-status.ts +136 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visits.ts +93 -0
- package/payload/platform/plugins/docs/references/deployment.md +14 -8
- package/payload/platform/plugins/docs/references/platform.md +4 -2
- package/payload/platform/templates/agents/public/IDENTITY.md +3 -3
- package/payload/server/maxy-edge.js +302 -33
- package/payload/server/public/activity.html +5 -5
- package/payload/server/public/agents.html +4 -4
- package/payload/server/public/assets/{AdminLoginScreens-DLogIsJT.js → AdminLoginScreens-C6e5jf0S.js} +1 -1
- package/payload/server/public/assets/AdminLoginScreens-C6e5jf0S.js.br +0 -0
- package/payload/server/public/assets/AdminLoginScreens-C6e5jf0S.js.gz +0 -0
- package/payload/server/public/assets/AdminShell-DStA0lfm.js +3 -0
- package/payload/server/public/assets/AdminShell-DStA0lfm.js.br +0 -0
- package/payload/server/public/assets/AdminShell-DStA0lfm.js.gz +0 -0
- package/payload/server/public/assets/{activity-AVJSFESV.js → activity--yyBcNfB.js} +1 -1
- package/payload/server/public/assets/activity--yyBcNfB.js.br +0 -0
- package/payload/server/public/assets/activity--yyBcNfB.js.gz +0 -0
- package/payload/server/public/assets/{admin-eFgbld42.js → admin-Dr6Jwe7W.js} +1 -1
- package/payload/server/public/assets/admin-Dr6Jwe7W.js.br +0 -0
- package/payload/server/public/assets/admin-Dr6Jwe7W.js.gz +0 -0
- package/payload/server/public/assets/{agents-CoWLZMf7.js → agents-odyVOb5E.js} +1 -1
- package/payload/server/public/assets/agents-odyVOb5E.js.br +0 -0
- package/payload/server/public/assets/agents-odyVOb5E.js.gz +0 -0
- package/payload/server/public/assets/{browser-C6K66_fw.js → browser-D1K_MoHt.js} +1 -1
- package/payload/server/public/assets/browser-D1K_MoHt.js.br +0 -0
- package/payload/server/public/assets/browser-D1K_MoHt.js.gz +0 -0
- package/payload/server/public/assets/{calendar-Dnw_cP4b.js → calendar-jqh9U7Ic.js} +1 -1
- package/payload/server/public/assets/calendar-jqh9U7Ic.js.br +0 -0
- package/payload/server/public/assets/calendar-jqh9U7Ic.js.gz +0 -0
- package/payload/server/public/assets/chat-QQHBdVor.js +1 -0
- package/payload/server/public/assets/chat-QQHBdVor.js.br +0 -0
- package/payload/server/public/assets/chat-QQHBdVor.js.gz +0 -0
- package/payload/server/public/assets/chevron-left-DwTXgKwr.js +1 -0
- package/payload/server/public/assets/clock-DOmh9iKX.js +1 -0
- package/payload/server/public/assets/clock-DOmh9iKX.js.br +0 -0
- package/payload/server/public/assets/{copy-kuNM0Etw.js → copy-DkuHwkaB.js} +1 -1
- package/payload/server/public/assets/copy-DkuHwkaB.js.br +0 -0
- package/payload/server/public/assets/copy-DkuHwkaB.js.gz +0 -0
- package/payload/server/public/assets/data-DYmTNqrb.js +1 -0
- package/payload/server/public/assets/data-DYmTNqrb.js.br +0 -0
- package/payload/server/public/assets/data-DYmTNqrb.js.gz +0 -0
- package/payload/server/public/assets/{file-text-BWkobrcx.js → file-text-C6o_NRjj.js} +1 -1
- package/payload/server/public/assets/file-text-C6o_NRjj.js.br +0 -0
- package/payload/server/public/assets/file-text-C6o_NRjj.js.gz +0 -0
- package/payload/server/public/assets/{graph-BGjTL5Vz.js → graph-BN30v5hJ.js} +1 -1
- package/payload/server/public/assets/graph-BN30v5hJ.js.br +0 -0
- package/payload/server/public/assets/graph-BN30v5hJ.js.gz +0 -0
- package/payload/server/public/assets/{graph-labels-Dvc5Kw_r.js → graph-labels-DHFyNXQt.js} +1 -1
- package/payload/server/public/assets/graph-labels-DHFyNXQt.js.br +0 -0
- package/payload/server/public/assets/graph-labels-DHFyNXQt.js.gz +0 -0
- package/payload/server/public/assets/{operator-CzBGv_he.js → operator-DwFYA4Fp.js} +1 -1
- package/payload/server/public/assets/operator-DwFYA4Fp.js.br +0 -0
- package/payload/server/public/assets/operator-DwFYA4Fp.js.gz +0 -0
- package/payload/server/public/assets/{page-DaySqamH.js → page-BALVRssF.js} +1 -1
- package/payload/server/public/assets/page-BALVRssF.js.br +0 -0
- package/payload/server/public/assets/page-BALVRssF.js.gz +0 -0
- package/payload/server/public/assets/{page-BIY4Ku8Q.js → page-CEWIP3VF.js} +1 -1
- package/payload/server/public/assets/page-CEWIP3VF.js.br +0 -0
- package/payload/server/public/assets/page-CEWIP3VF.js.gz +0 -0
- package/payload/server/public/assets/play-Bg-BY_oj.js +1 -0
- package/payload/server/public/assets/play-Bg-BY_oj.js.br +0 -0
- package/payload/server/public/assets/play-Bg-BY_oj.js.gz +0 -0
- package/payload/server/public/assets/{public-pwDSvhBv.js → public-Ceb0n7Km.js} +1 -1
- package/payload/server/public/assets/public-Ceb0n7Km.js.br +0 -0
- package/payload/server/public/assets/public-Ceb0n7Km.js.gz +0 -0
- package/payload/server/public/assets/{rotate-ccw-B6s7tkEt.js → rotate-ccw-CDYdx91V.js} +1 -1
- package/payload/server/public/assets/rotate-ccw-CDYdx91V.js.gz +0 -0
- package/payload/server/public/assets/{routines-CFlyRdYg.js → routines-DwNTih0y.js} +1 -1
- package/payload/server/public/assets/routines-DwNTih0y.js.br +0 -0
- package/payload/server/public/assets/routines-DwNTih0y.js.gz +0 -0
- package/payload/server/public/assets/{skills-C45kfL_Q.js → skills-TIDCdOLQ.js} +1 -1
- package/payload/server/public/assets/skills-TIDCdOLQ.js.br +0 -0
- package/payload/server/public/assets/skills-TIDCdOLQ.js.gz +0 -0
- package/payload/server/public/assets/{tasks-BwoCTCu5.js → tasks-eTXvaWnd.js} +1 -1
- package/payload/server/public/assets/tasks-eTXvaWnd.js.br +0 -0
- package/payload/server/public/assets/tasks-eTXvaWnd.js.gz +0 -0
- package/payload/server/public/assets/{triangle-alert-B3bthSzx.js → triangle-alert-JwLSrj17.js} +1 -1
- package/payload/server/public/assets/triangle-alert-JwLSrj17.js.br +0 -0
- package/payload/server/public/assets/triangle-alert-JwLSrj17.js.gz +0 -0
- package/payload/server/public/assets/{useCopyFeedback-BzJAkFEb.js → useCopyFeedback-DjTkNw2p.js} +1 -1
- package/payload/server/public/assets/useCopyFeedback-DjTkNw2p.js.br +0 -0
- package/payload/server/public/assets/useCopyFeedback-DjTkNw2p.js.gz +0 -0
- package/payload/server/public/assets/useMediaQuery-BsBzbWaf.css +1 -0
- package/payload/server/public/assets/useMediaQuery-BsBzbWaf.css.br +0 -0
- package/payload/server/public/assets/useMediaQuery-BsBzbWaf.css.gz +0 -0
- package/payload/server/public/assets/useMediaQuery-D8VDA0RN.js +14 -0
- package/payload/server/public/assets/useMediaQuery-D8VDA0RN.js.br +0 -0
- package/payload/server/public/assets/useMediaQuery-D8VDA0RN.js.gz +0 -0
- package/payload/server/public/assets/{useVoiceRecorder-_qI_BAOo.js → useVoiceRecorder-B8vxObZG.js} +1 -1
- package/payload/server/public/assets/useVoiceRecorder-B8vxObZG.js.br +0 -0
- package/payload/server/public/assets/useVoiceRecorder-B8vxObZG.js.gz +0 -0
- package/payload/server/public/assets/{wrench-Bqux9jaw.js → wrench-DutUiRu7.js} +1 -1
- package/payload/server/public/assets/wrench-DutUiRu7.js.br +0 -0
- package/payload/server/public/assets/wrench-DutUiRu7.js.gz +0 -0
- package/payload/server/public/browser.html +4 -4
- package/payload/server/public/calendar.html +7 -7
- package/payload/server/public/chat.html +13 -13
- package/payload/server/public/data.html +11 -11
- package/payload/server/public/graph.html +9 -9
- package/payload/server/public/index.html +13 -13
- package/payload/server/public/operator.html +14 -14
- package/payload/server/public/public.html +13 -13
- package/payload/server/public/routines.html +7 -7
- package/payload/server/public/skills.html +5 -5
- package/payload/server/public/tasks.html +6 -6
- package/payload/server/server.js +1118 -636
- package/payload/server/public/assets/AdminLoginScreens-DLogIsJT.js.br +0 -0
- package/payload/server/public/assets/AdminLoginScreens-DLogIsJT.js.gz +0 -0
- package/payload/server/public/assets/AdminShell-B67tPTyE.js +0 -2
- package/payload/server/public/assets/AdminShell-B67tPTyE.js.br +0 -0
- package/payload/server/public/assets/AdminShell-B67tPTyE.js.gz +0 -0
- package/payload/server/public/assets/activity-AVJSFESV.js.br +0 -0
- package/payload/server/public/assets/activity-AVJSFESV.js.gz +0 -0
- package/payload/server/public/assets/admin-eFgbld42.js.br +0 -0
- package/payload/server/public/assets/admin-eFgbld42.js.gz +0 -0
- package/payload/server/public/assets/agents-CoWLZMf7.js.br +0 -0
- package/payload/server/public/assets/agents-CoWLZMf7.js.gz +0 -0
- package/payload/server/public/assets/browser-C6K66_fw.js.br +0 -0
- package/payload/server/public/assets/browser-C6K66_fw.js.gz +0 -0
- package/payload/server/public/assets/calendar-Dnw_cP4b.js.br +0 -0
- package/payload/server/public/assets/calendar-Dnw_cP4b.js.gz +0 -0
- package/payload/server/public/assets/chat-Cm0fkpg1.js +0 -1
- package/payload/server/public/assets/chat-Cm0fkpg1.js.br +0 -0
- package/payload/server/public/assets/chat-Cm0fkpg1.js.gz +0 -0
- package/payload/server/public/assets/chevron-left-CjRH-BtF.js +0 -1
- package/payload/server/public/assets/chevron-left-CjRH-BtF.js.br +0 -0
- package/payload/server/public/assets/clock-wbYEt1kM.js +0 -1
- package/payload/server/public/assets/clock-wbYEt1kM.js.br +0 -0
- package/payload/server/public/assets/clock-wbYEt1kM.js.gz +0 -0
- package/payload/server/public/assets/copy-kuNM0Etw.js.br +0 -0
- package/payload/server/public/assets/copy-kuNM0Etw.js.gz +0 -0
- package/payload/server/public/assets/data-c47T2NCd.js +0 -1
- package/payload/server/public/assets/data-c47T2NCd.js.br +0 -1
- package/payload/server/public/assets/data-c47T2NCd.js.gz +0 -0
- package/payload/server/public/assets/file-text-BWkobrcx.js.br +0 -0
- package/payload/server/public/assets/file-text-BWkobrcx.js.gz +0 -0
- package/payload/server/public/assets/graph-BGjTL5Vz.js.br +0 -0
- package/payload/server/public/assets/graph-BGjTL5Vz.js.gz +0 -0
- package/payload/server/public/assets/graph-labels-Dvc5Kw_r.js.br +0 -0
- package/payload/server/public/assets/graph-labels-Dvc5Kw_r.js.gz +0 -0
- package/payload/server/public/assets/operator-CzBGv_he.js.br +0 -0
- package/payload/server/public/assets/operator-CzBGv_he.js.gz +0 -0
- package/payload/server/public/assets/page-BIY4Ku8Q.js.br +0 -0
- package/payload/server/public/assets/page-BIY4Ku8Q.js.gz +0 -0
- package/payload/server/public/assets/page-DaySqamH.js.br +0 -0
- package/payload/server/public/assets/page-DaySqamH.js.gz +0 -0
- package/payload/server/public/assets/play-C8RA52_i.js +0 -1
- package/payload/server/public/assets/play-C8RA52_i.js.br +0 -0
- package/payload/server/public/assets/play-C8RA52_i.js.gz +0 -0
- package/payload/server/public/assets/public-pwDSvhBv.js.br +0 -0
- package/payload/server/public/assets/public-pwDSvhBv.js.gz +0 -0
- package/payload/server/public/assets/rotate-ccw-B6s7tkEt.js.br +0 -0
- package/payload/server/public/assets/rotate-ccw-B6s7tkEt.js.gz +0 -0
- package/payload/server/public/assets/routines-CFlyRdYg.js.br +0 -0
- package/payload/server/public/assets/routines-CFlyRdYg.js.gz +0 -0
- package/payload/server/public/assets/skills-C45kfL_Q.js.br +0 -0
- package/payload/server/public/assets/skills-C45kfL_Q.js.gz +0 -0
- package/payload/server/public/assets/tasks-BwoCTCu5.js.br +0 -0
- package/payload/server/public/assets/tasks-BwoCTCu5.js.gz +0 -0
- package/payload/server/public/assets/triangle-alert-B3bthSzx.js.br +0 -0
- package/payload/server/public/assets/triangle-alert-B3bthSzx.js.gz +0 -0
- package/payload/server/public/assets/useCopyFeedback-BzJAkFEb.js.br +0 -0
- package/payload/server/public/assets/useCopyFeedback-BzJAkFEb.js.gz +0 -0
- package/payload/server/public/assets/useMediaQuery-DJ6XeNN7.js +0 -14
- package/payload/server/public/assets/useMediaQuery-DJ6XeNN7.js.br +0 -0
- package/payload/server/public/assets/useMediaQuery-DJ6XeNN7.js.gz +0 -0
- package/payload/server/public/assets/useMediaQuery-DW_xRrMl.css +0 -1
- package/payload/server/public/assets/useMediaQuery-DW_xRrMl.css.br +0 -0
- package/payload/server/public/assets/useMediaQuery-DW_xRrMl.css.gz +0 -0
- package/payload/server/public/assets/useVoiceRecorder-_qI_BAOo.js.br +0 -0
- package/payload/server/public/assets/useVoiceRecorder-_qI_BAOo.js.gz +0 -0
- package/payload/server/public/assets/wrench-Bqux9jaw.js.br +0 -0
- package/payload/server/public/assets/wrench-Bqux9jaw.js.gz +0 -0
|
@@ -90,6 +90,7 @@ For each person:
|
|
|
90
90
|
```bash
|
|
91
91
|
node platform/plugins/cloudflare/bin/portal-enrol.mjs --owner <ownerId> --name <name> \
|
|
92
92
|
--account <accountId> [--storage-model shared|dedicated] [--folders <a,b,c>] \
|
|
93
|
+
[--record-scope own|account] \
|
|
93
94
|
| npx wrangler d1 execute <portalDbName> --remote --file=-
|
|
94
95
|
```
|
|
95
96
|
|
|
@@ -124,6 +125,28 @@ the same upsert that rotates the passcode. The grant lives only in the portal's
|
|
|
124
125
|
below the account's exposed set and never widens past it: a portal compromise returns a person to the
|
|
125
126
|
account set, which the install re-derives and enforces regardless.
|
|
126
127
|
|
|
128
|
+
`--record-scope` is a different question from `--folders` and answers it for RECORDS rather than
|
|
129
|
+
files: `own` lets the person read only records carrying their own id, `account` lets them read every
|
|
130
|
+
record for the account. Two values and no third; a typo is refused rather than resolved to whichever
|
|
131
|
+
is nearer. Omitting it gives `own`, so nobody is granted account-wide visibility by leaving a flag
|
|
132
|
+
off. Re-enrolment replaces it, the same upsert that rotates the passcode, and that is the only way
|
|
133
|
+
to narrow a person back. The office person who schedules gets `account`; a field worker gets `own`.
|
|
134
|
+
|
|
135
|
+
The scope's only reader is `functions/api/visits.ts`, which the template ships. An account's own page
|
|
136
|
+
renders what that endpoint returns and never re-implements the filter — a gate written once per
|
|
137
|
+
client is a gate got wrong once per client.
|
|
138
|
+
|
|
139
|
+
**On a store that predates this column, run the `ALTER` once before enrolling anyone.** `IF NOT
|
|
140
|
+
EXISTS` adds no column to an existing table, so `schema.sql` will not do it for you and enrolment's
|
|
141
|
+
INSERT names the column, which means it fails loudly rather than writing a scopeless row:
|
|
142
|
+
|
|
143
|
+
```sql
|
|
144
|
+
ALTER TABLE people ADD COLUMN recordScope TEXT NOT NULL DEFAULT 'own';
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
No backfill follows it. Everyone already enrolled lands on the narrow scope, which is the intended
|
|
148
|
+
outcome, and widening one person is a re-run of enrolment for that person.
|
|
149
|
+
|
|
127
150
|
**A passcode is never stored in plaintext** — not in `data-portal.json`, not in a log line, nowhere
|
|
128
151
|
on the device. The operator conveys it to the person out of band. Rotating a passcode is a re-run of
|
|
129
152
|
the script for that person; there is no separate editor.
|
|
@@ -47,6 +47,22 @@
|
|
|
47
47
|
-- to a `people` table that predates it, and enrolment's INSERT names `folders`,
|
|
48
48
|
-- so it fails loudly on such a table. Add it by hand in that case:
|
|
49
49
|
-- ALTER TABLE people ADD COLUMN folders TEXT NOT NULL DEFAULT '';
|
|
50
|
+
-- `recordScope` is which RECORDS this person may read, as distinct from
|
|
51
|
+
-- `folders` above, which is which FILES they may browse. Two values and no
|
|
52
|
+
-- third: `own` sees only records carrying their ownerId, `account` sees every
|
|
53
|
+
-- record for the account. `functions/api/visits.ts` is its only reader, and it
|
|
54
|
+
-- is deliberately the only one — a gate written once per client is a gate got
|
|
55
|
+
-- wrong once per client.
|
|
56
|
+
--
|
|
57
|
+
-- The default is the NARROW value, and that is the whole of the migration
|
|
58
|
+
-- story. Like `accountId` and `folders`, IF NOT EXISTS will NOT add this column
|
|
59
|
+
-- to a `people` table that predates it, and enrolment's INSERT names it, so
|
|
60
|
+
-- such a table fails loudly. Add it by hand in that case:
|
|
61
|
+
-- ALTER TABLE people ADD COLUMN recordScope TEXT NOT NULL DEFAULT 'own';
|
|
62
|
+
-- Every person already enrolled then lands on the narrow scope. A default of
|
|
63
|
+
-- `account` would have handed all of them account-wide visibility by migration,
|
|
64
|
+
-- which is the one thing a scope column must never do.
|
|
65
|
+
--
|
|
50
66
|
-- The key is (accountId, ownerId), not ownerId alone. On the shared store two
|
|
51
67
|
-- clients may each enrol a person called `rob`, and a bare ownerId key would
|
|
52
68
|
-- reject the second enrolment outright. Worse, the enrolment upsert would treat
|
|
@@ -60,6 +76,7 @@ CREATE TABLE IF NOT EXISTS people (
|
|
|
60
76
|
createdAt TEXT NOT NULL,
|
|
61
77
|
accountId TEXT NOT NULL DEFAULT '',
|
|
62
78
|
folders TEXT NOT NULL DEFAULT '',
|
|
79
|
+
recordScope TEXT NOT NULL DEFAULT 'own',
|
|
63
80
|
PRIMARY KEY (accountId, ownerId)
|
|
64
81
|
);
|
|
65
82
|
|
|
@@ -228,3 +245,80 @@ CREATE TABLE IF NOT EXISTS directory_state (
|
|
|
228
245
|
accountId TEXT PRIMARY KEY,
|
|
229
246
|
currentGeneration INTEGER NOT NULL
|
|
230
247
|
);
|
|
248
|
+
|
|
249
|
+
-- One row per :Visit the device has published, per staging generation
|
|
250
|
+
-- (Task 2425). Metadata only, and only what the ontology already declares
|
|
251
|
+
-- (schema-construction.md): a job sheet's contents are files, which the
|
|
252
|
+
-- manifest above already carries.
|
|
253
|
+
--
|
|
254
|
+
-- READ THE ASYMMETRY WITH THE COLUMN NOTES ABOVE. Those warn that
|
|
255
|
+
-- IF NOT EXISTS adds no COLUMN to an existing table. It does create an absent
|
|
256
|
+
-- TABLE, so these three need no hand step on a database that predates them.
|
|
257
|
+
-- Stated because a reader who has just absorbed the column warning would
|
|
258
|
+
-- otherwise carry it across and go looking for a migration that is not needed.
|
|
259
|
+
--
|
|
260
|
+
-- `ownerId` is the enrolled portal person the visit is assigned to, resolved by
|
|
261
|
+
-- the push from an explicit `portalOwnerId` on the :Person carrying :Engineer.
|
|
262
|
+
-- It is '' when the engineer carries no such property. That row is NOT dropped:
|
|
263
|
+
-- it stays invisible to every own-scoped person, visible to account-scoped
|
|
264
|
+
-- ones, and counted by the audit as `unboundVisits`, so an unbound engineer is
|
|
265
|
+
-- found in the journal rather than by a worker opening an empty day.
|
|
266
|
+
--
|
|
267
|
+
-- `generation` and `visits_state` mirror `directory`/`directory_state` exactly,
|
|
268
|
+
-- and for the same reason: the push writes a whole generation, moves a pointer
|
|
269
|
+
-- in one forward-only statement, then sweeps what the pointer no longer names.
|
|
270
|
+
-- A listing that races a push reads the old set or the new one, never a
|
|
271
|
+
-- half-built one.
|
|
272
|
+
CREATE TABLE IF NOT EXISTS visits (
|
|
273
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
274
|
+
accountId TEXT NOT NULL,
|
|
275
|
+
visitId TEXT NOT NULL,
|
|
276
|
+
ownerId TEXT NOT NULL DEFAULT '',
|
|
277
|
+
jobId TEXT NOT NULL DEFAULT '',
|
|
278
|
+
status TEXT NOT NULL DEFAULT '',
|
|
279
|
+
startDate TEXT NOT NULL DEFAULT '',
|
|
280
|
+
purpose TEXT NOT NULL DEFAULT '',
|
|
281
|
+
generation INTEGER NOT NULL DEFAULT 0,
|
|
282
|
+
UNIQUE (accountId, visitId, generation)
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
CREATE INDEX IF NOT EXISTS visits_account ON visits (accountId);
|
|
286
|
+
-- The own-scoped read filters on both columns, so it carries its own index
|
|
287
|
+
-- rather than riding the account one.
|
|
288
|
+
CREATE INDEX IF NOT EXISTS visits_owner ON visits (accountId, ownerId);
|
|
289
|
+
|
|
290
|
+
-- Which generation of an account's visit rows is the live one. The flip of this
|
|
291
|
+
-- single row is the atomic point of a push, exactly as directory_state is.
|
|
292
|
+
-- An account with no row here has never completed one, and every reader joins
|
|
293
|
+
-- through this table, so it shows nothing rather than a staged generation that
|
|
294
|
+
-- no flip ever blessed.
|
|
295
|
+
CREATE TABLE IF NOT EXISTS visits_state (
|
|
296
|
+
accountId TEXT PRIMARY KEY,
|
|
297
|
+
currentGeneration INTEGER NOT NULL
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
-- The inbound half: a status a person set on the portal, waiting for the device
|
|
301
|
+
-- to carry it into the graph (Task 2425).
|
|
302
|
+
--
|
|
303
|
+
-- `claimed` is set only AFTER the graph write returns. Claiming first is
|
|
304
|
+
-- unrecoverable: if the write then failed, the row would read as delivered and
|
|
305
|
+
-- the person's edit would be gone with nothing saying so. Claiming after costs
|
|
306
|
+
-- a repeated MERGE on a lost response, which is idempotent.
|
|
307
|
+
--
|
|
308
|
+
-- A row whose visitId resolves to no :Visit in that account stays unclaimed and
|
|
309
|
+
-- is counted by the audit. It is never written under another id and never
|
|
310
|
+
-- dropped: a dropped edit is a promise broken to the person who made it, and it
|
|
311
|
+
-- emits nothing at the time it happens.
|
|
312
|
+
CREATE TABLE IF NOT EXISTS visit_status (
|
|
313
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
314
|
+
accountId TEXT NOT NULL,
|
|
315
|
+
visitId TEXT NOT NULL,
|
|
316
|
+
ownerId TEXT NOT NULL,
|
|
317
|
+
status TEXT NOT NULL,
|
|
318
|
+
statusAt TEXT NOT NULL,
|
|
319
|
+
claimed INTEGER NOT NULL DEFAULT 0
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
-- The pull selects unclaimed rows for one account every sixty seconds, so the
|
|
323
|
+
-- pair carries its own index or every cycle is a full scan.
|
|
324
|
+
CREATE INDEX IF NOT EXISTS visit_status_pending ON visit_status (accountId, claimed);
|
|
@@ -60,16 +60,32 @@ export async function mintSession(
|
|
|
60
60
|
return sessionId
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Which records a person may read.
|
|
65
|
+
*
|
|
66
|
+
* Two values and no third: `own` sees only records carrying their ownerId,
|
|
67
|
+
* `account` sees every record for the account. Distinct from `folders`, which
|
|
68
|
+
* governs which FILES they browse — a person can hold any combination, and
|
|
69
|
+
* neither narrows or widens the other.
|
|
70
|
+
*/
|
|
71
|
+
export type RecordScope = 'own' | 'account'
|
|
72
|
+
|
|
63
73
|
export async function resolveSession(
|
|
64
74
|
db: D1Database,
|
|
65
75
|
sessionId: string,
|
|
66
76
|
accountId: string,
|
|
67
77
|
nowMs: number,
|
|
68
|
-
): Promise<{
|
|
78
|
+
): Promise<{
|
|
79
|
+
ownerId: string
|
|
80
|
+
accountId: string
|
|
81
|
+
folders: string[]
|
|
82
|
+
recordScope: RecordScope
|
|
83
|
+
} | null> {
|
|
69
84
|
if (!sessionId) return null
|
|
70
85
|
const row = await db
|
|
71
86
|
.prepare(
|
|
72
|
-
`SELECT s.ownerId AS ownerId, p.accountId AS accountId, p.folders AS folders
|
|
87
|
+
`SELECT s.ownerId AS ownerId, p.accountId AS accountId, p.folders AS folders,
|
|
88
|
+
p.recordScope AS recordScope
|
|
73
89
|
FROM sessions s
|
|
74
90
|
JOIN people p ON p.ownerId = s.ownerId AND p.accountId = s.accountId
|
|
75
91
|
WHERE s.sessionId = ?
|
|
@@ -78,7 +94,12 @@ export async function resolveSession(
|
|
|
78
94
|
AND s.pcCheck = substr(p.hash, 1, 16)`,
|
|
79
95
|
)
|
|
80
96
|
.bind(sessionId, accountId, nowMs)
|
|
81
|
-
.first<{
|
|
97
|
+
.first<{
|
|
98
|
+
ownerId: string
|
|
99
|
+
accountId: string
|
|
100
|
+
folders: string
|
|
101
|
+
recordScope: unknown
|
|
102
|
+
}>()
|
|
82
103
|
if (!row || typeof row.ownerId !== 'string') return null
|
|
83
104
|
// The join matches on BOTH columns and the lookup is already scoped to this
|
|
84
105
|
// portal's own account, which is what stops a same-named person in another
|
|
@@ -92,7 +113,13 @@ export async function resolveSession(
|
|
|
92
113
|
// means no grant, which the two client surfaces read as no filter (full set).
|
|
93
114
|
const folders =
|
|
94
115
|
typeof row.folders === 'string' ? row.folders.split(',').map((s) => s.trim()).filter(Boolean) : []
|
|
95
|
-
|
|
116
|
+
// ONLY the exact token widens the scope. An empty string, an unknown value, a
|
|
117
|
+
// null, a legacy row with no such column: every one of them is `own`. An
|
|
118
|
+
// unreadable scope is the narrow scope, because the alternative is a read
|
|
119
|
+
// failure that hands out account-wide visibility — the one outcome this
|
|
120
|
+
// column exists to prevent.
|
|
121
|
+
const recordScope: RecordScope = row.recordScope === 'account' ? 'account' : 'own'
|
|
122
|
+
return { ownerId: row.ownerId, accountId, folders, recordScope }
|
|
96
123
|
}
|
|
97
124
|
|
|
98
125
|
/** Whether a person's folder grant admits `relPath`. An empty grant admits
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { Handler, Logger, PortalEnv } from './_lib/types'
|
|
2
|
+
import { readSessionCookie, resolveSession } from './_lib/session'
|
|
3
|
+
import { q } from './_lib/log'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The longest status a person may set.
|
|
7
|
+
*
|
|
8
|
+
* The value reaches `:Visit.status` through the device-side pull, so it is
|
|
9
|
+
* graph content arriving from the open web. It is refused at the door rather
|
|
10
|
+
* than truncated: a truncated status is a different status, silently, and the
|
|
11
|
+
* person who set it is never told.
|
|
12
|
+
*/
|
|
13
|
+
export const MAX_STATUS_LENGTH = 64
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Record a status a person set against one of their visits.
|
|
17
|
+
*
|
|
18
|
+
* THE GATE IS THE SELECT, not a check beside it. The insert is
|
|
19
|
+
* `INSERT ... SELECT` over the same `visits` join, under the same two filters
|
|
20
|
+
* the read applies — the account from the baked-in var, and the owner when and
|
|
21
|
+
* only when the person's scope is `own`. A visit the person may not see admits
|
|
22
|
+
* no row, so `meta.changes` is 0 and the handler answers 404. There is no
|
|
23
|
+
* separate authorisation step that a later edit could reorder past the write.
|
|
24
|
+
*
|
|
25
|
+
* This ships in the platform template for the same reason the read does, and
|
|
26
|
+
* more sharply. A read leak shows someone a working day that is not theirs; a
|
|
27
|
+
* write leak lets them change it. Neither gate is something to re-implement per
|
|
28
|
+
* client.
|
|
29
|
+
*
|
|
30
|
+
* `ownerId` is stamped from the resolved session and never from the body. A
|
|
31
|
+
* request that names one is ignored rather than refused, because the field
|
|
32
|
+
* carries no authority here — the session already decided who is writing.
|
|
33
|
+
*/
|
|
34
|
+
export async function processVisitStatus(
|
|
35
|
+
body: unknown,
|
|
36
|
+
sessionId: string,
|
|
37
|
+
env: PortalEnv,
|
|
38
|
+
log: Logger,
|
|
39
|
+
nowMs: number,
|
|
40
|
+
): Promise<Handler> {
|
|
41
|
+
const session = await resolveSession(env.DB, sessionId, env.PORTAL_ACCOUNT_ID, nowMs)
|
|
42
|
+
if (!session) {
|
|
43
|
+
log('[data-portal] op=visit-status owner=none result=denied')
|
|
44
|
+
return { status: 401, payload: { ok: false, error: 'denied' } }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const b = (body ?? {}) as Record<string, unknown>
|
|
48
|
+
const visitId = typeof b.visitId === 'string' ? b.visitId : ''
|
|
49
|
+
const status = typeof b.status === 'string' ? b.status : ''
|
|
50
|
+
if (!visitId || !status) {
|
|
51
|
+
log(`[data-portal] op=visit-status owner=${q(session.ownerId)} result=bad-request`)
|
|
52
|
+
return { status: 400, payload: { ok: false, error: 'visitId and status are required' } }
|
|
53
|
+
}
|
|
54
|
+
if (status.length > MAX_STATUS_LENGTH) {
|
|
55
|
+
log(`[data-portal] op=visit-status owner=${q(session.ownerId)} result=status-too-long`)
|
|
56
|
+
return { status: 400, payload: { ok: false, error: 'status too long' } }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const scoped = session.recordScope === 'own'
|
|
60
|
+
const sql =
|
|
61
|
+
`INSERT INTO visit_status (accountId, ownerId, status, statusAt, claimed, visitId)
|
|
62
|
+
SELECT ?, ?, ?, ?, 0, v.visitId
|
|
63
|
+
FROM visits v
|
|
64
|
+
JOIN visits_state s
|
|
65
|
+
ON s.accountId = v.accountId AND s.currentGeneration = v.generation
|
|
66
|
+
WHERE v.accountId = ?` +
|
|
67
|
+
(scoped ? ' AND v.ownerId = ?' : '') +
|
|
68
|
+
' AND v.visitId = ?'
|
|
69
|
+
const statusAt = new Date(nowMs).toISOString()
|
|
70
|
+
const stmt = env.DB.prepare(sql)
|
|
71
|
+
const bound = scoped
|
|
72
|
+
? stmt.bind(
|
|
73
|
+
env.PORTAL_ACCOUNT_ID,
|
|
74
|
+
session.ownerId,
|
|
75
|
+
status,
|
|
76
|
+
statusAt,
|
|
77
|
+
env.PORTAL_ACCOUNT_ID,
|
|
78
|
+
session.ownerId,
|
|
79
|
+
visitId,
|
|
80
|
+
)
|
|
81
|
+
: stmt.bind(
|
|
82
|
+
env.PORTAL_ACCOUNT_ID,
|
|
83
|
+
session.ownerId,
|
|
84
|
+
status,
|
|
85
|
+
statusAt,
|
|
86
|
+
env.PORTAL_ACCOUNT_ID,
|
|
87
|
+
visitId,
|
|
88
|
+
)
|
|
89
|
+
const result = await bound.run()
|
|
90
|
+
const changes = result.meta?.changes ?? 0
|
|
91
|
+
|
|
92
|
+
if (changes === 0) {
|
|
93
|
+
// One answer for "no such visit" and "not yours", deliberately. The two are
|
|
94
|
+
// the same fact to a caller who may not see it, and distinguishing them
|
|
95
|
+
// would let anyone enumerate the account's visit ids.
|
|
96
|
+
log(
|
|
97
|
+
`[data-portal] op=visit-status owner=${q(session.ownerId)} account=${q(session.accountId)} ` +
|
|
98
|
+
`scope=${session.recordScope} result=not-found`,
|
|
99
|
+
)
|
|
100
|
+
return { status: 404, payload: { ok: false, error: 'not found' } }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Counts, the scope and the outcome. Never the status text: it describes what
|
|
104
|
+
// a real person was doing.
|
|
105
|
+
log(
|
|
106
|
+
`[data-portal] op=visit-status owner=${q(session.ownerId)} account=${q(session.accountId)} ` +
|
|
107
|
+
`scope=${session.recordScope} queued=${changes} result=ok`,
|
|
108
|
+
)
|
|
109
|
+
return { status: 200, payload: { ok: true, queued: changes } }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface PagesContext {
|
|
113
|
+
request: Request
|
|
114
|
+
env: PortalEnv
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function onRequestPost(context: PagesContext): Promise<Response> {
|
|
118
|
+
const sessionId = readSessionCookie(context.request.headers.get('cookie')) ?? ''
|
|
119
|
+
let body: unknown = null
|
|
120
|
+
try {
|
|
121
|
+
body = await context.request.json()
|
|
122
|
+
} catch {
|
|
123
|
+
// A malformed body is a bad request, not a crash. processVisitStatus
|
|
124
|
+
// resolves the session first, so an unauthenticated caller still gets 401
|
|
125
|
+
// rather than learning that the body parse is what failed.
|
|
126
|
+
body = null
|
|
127
|
+
}
|
|
128
|
+
const { status, payload } = await processVisitStatus(
|
|
129
|
+
body,
|
|
130
|
+
sessionId,
|
|
131
|
+
context.env,
|
|
132
|
+
(line) => console.log(line),
|
|
133
|
+
Date.now(),
|
|
134
|
+
)
|
|
135
|
+
return Response.json(payload, { status })
|
|
136
|
+
}
|
package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visits.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Handler, Logger, PortalEnv } from './_lib/types'
|
|
2
|
+
import { readSessionCookie, resolveSession } from './_lib/session'
|
|
3
|
+
import { q } from './_lib/log'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One published visit, as an account's own page renders it.
|
|
7
|
+
*
|
|
8
|
+
* The ontology's own fields (`schema-construction.md`) and the portal person the
|
|
9
|
+
* visit is assigned to, and nothing else. A job sheet's contents are the
|
|
10
|
+
* account's to design, and they are files, which the Task 1910 exchange already
|
|
11
|
+
* carries — so nothing of theirs belongs in this shape.
|
|
12
|
+
*/
|
|
13
|
+
export interface VisitRow {
|
|
14
|
+
visitId: string
|
|
15
|
+
ownerId: string
|
|
16
|
+
jobId: string
|
|
17
|
+
status: string
|
|
18
|
+
startDate: string
|
|
19
|
+
purpose: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The visits a signed-in person may read.
|
|
24
|
+
*
|
|
25
|
+
* TWO FILTERS, BOTH SERVER-SIDE, NEITHER REACHABLE FROM THE REQUEST. The
|
|
26
|
+
* account comes from the baked-in var for the reason `types.ts` gives at
|
|
27
|
+
* PORTAL_ACCOUNT_ID; the owner filter is applied when, and only when, the
|
|
28
|
+
* person's scope is `own`, read off the `people` join. No request parameter
|
|
29
|
+
* names either one, so there is no widening a caller can ask for.
|
|
30
|
+
*
|
|
31
|
+
* THIS FUNCTION IS THE ONLY READER OF `recordScope`, and that is deliberate. A
|
|
32
|
+
* scope column with no reader would be an unused column rather than a boundary.
|
|
33
|
+
* It ships here rather than being left for each account to write in its own copy
|
|
34
|
+
* of this tree, because a gate written once per client is a gate got wrong once
|
|
35
|
+
* per client — and this is the boundary that lets the surface sit on the open
|
|
36
|
+
* web.
|
|
37
|
+
*/
|
|
38
|
+
export async function processVisits(
|
|
39
|
+
sessionId: string,
|
|
40
|
+
env: PortalEnv,
|
|
41
|
+
log: Logger,
|
|
42
|
+
nowMs: number,
|
|
43
|
+
): Promise<Handler> {
|
|
44
|
+
const session = await resolveSession(env.DB, sessionId, env.PORTAL_ACCOUNT_ID, nowMs)
|
|
45
|
+
if (!session) {
|
|
46
|
+
log('[data-portal] op=visits owner=none result=denied')
|
|
47
|
+
return { status: 401, payload: { ok: false, error: 'denied' } }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const scoped = session.recordScope === 'own'
|
|
51
|
+
// Read through the generation pointer, so a listing that races a push sees one
|
|
52
|
+
// generation whole rather than a half-built one — the same join `files.ts`
|
|
53
|
+
// makes against directory_state, for the same reason.
|
|
54
|
+
const sql =
|
|
55
|
+
`SELECT v.visitId AS visitId, v.ownerId AS ownerId, v.jobId AS jobId,
|
|
56
|
+
v.status AS status, v.startDate AS startDate, v.purpose AS purpose
|
|
57
|
+
FROM visits v
|
|
58
|
+
JOIN visits_state s
|
|
59
|
+
ON s.accountId = v.accountId AND s.currentGeneration = v.generation
|
|
60
|
+
WHERE v.accountId = ?` +
|
|
61
|
+
(scoped ? ' AND v.ownerId = ?' : '') +
|
|
62
|
+
' ORDER BY v.startDate ASC, v.visitId ASC'
|
|
63
|
+
const stmt = env.DB.prepare(sql)
|
|
64
|
+
const bound = scoped
|
|
65
|
+
? stmt.bind(env.PORTAL_ACCOUNT_ID, session.ownerId)
|
|
66
|
+
: stmt.bind(env.PORTAL_ACCOUNT_ID)
|
|
67
|
+
const rows = await bound.all<VisitRow>()
|
|
68
|
+
const visits = rows.results ?? []
|
|
69
|
+
|
|
70
|
+
// Counts and the scope, never a visitId or a purpose: those are a real
|
|
71
|
+
// person's job, and a Pages log is not where they belong.
|
|
72
|
+
log(
|
|
73
|
+
`[data-portal] op=visits owner=${q(session.ownerId)} account=${q(session.accountId)} ` +
|
|
74
|
+
`scope=${session.recordScope} visits=${visits.length} result=ok`,
|
|
75
|
+
)
|
|
76
|
+
return { status: 200, payload: { ok: true, visits } }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface PagesContext {
|
|
80
|
+
request: Request
|
|
81
|
+
env: PortalEnv
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function onRequestGet(context: PagesContext): Promise<Response> {
|
|
85
|
+
const sessionId = readSessionCookie(context.request.headers.get('cookie')) ?? ''
|
|
86
|
+
const { status, payload } = await processVisits(
|
|
87
|
+
sessionId,
|
|
88
|
+
context.env,
|
|
89
|
+
(line) => console.log(line),
|
|
90
|
+
Date.now(),
|
|
91
|
+
)
|
|
92
|
+
return Response.json(payload, { status })
|
|
93
|
+
}
|
|
@@ -201,11 +201,11 @@ The logs will show which service failed to start and why. Common causes:
|
|
|
201
201
|
Each installed brand runs two per-brand `--user` systemd units (earlier platform fixes + — unit filenames are prefixed with the brand's `hostname` so two brands on the same device never share a unit file):
|
|
202
202
|
|
|
203
203
|
- `{hostname}.service` — the admin + public HTTP server on `127.0.0.1:19201` (public port + 1). Restarted by the upgrade flow; short downtime is expected during steps 8→11 of an upgrade. An earlier fix: the unit carries two port env vars — `PORT=<public>` (canonical public port, read by the upgrade detector) and `MAXY_UI_INTERNAL_PORT=<public+1>` (the port maxy-ui actually binds).
|
|
204
|
-
- `{hostname}-edge.service` — the always-on public listener on the configured port (default 19200). Reverse-proxies HTTP to the main brand service and handles `/websockify` (VNC) WebSocket upgrades locally. An earlier fix: also hosts `/api/admin/actions/*` and `/api/admin/version*` — the Software Update modal's own routes — so
|
|
204
|
+
- `{hostname}-edge.service` — the always-on public listener on the configured port (default 19200). Reverse-proxies HTTP to the main brand service and handles `/websockify` (VNC) WebSocket upgrades locally. An earlier fix: also hosts `/api/admin/actions/*` and `/api/admin/version*` — the Software Update modal's own routes — so they survive the brand service's restart window. It is restarted by the upgrade flow too (`packages/create-maxy-code/src/index.ts:5143`, after the brand service is stopped and before it is restarted), so the update stream drops near the end of every run and resumes by byte offset. It is not a connection that survives; the log file is what survives, and the browser's `EventSource` replays `Last-Event-ID` to pick the tail back up. The operator views the device's Chromium in the admin UI via the standalone `/browser` page (peer to `/graph` and `/data`) and on the Claude sign-in screen; both render CDP screencast frames over `/cdp-screencast`, not the framebuffer. Virtual-mode Chromium is headless, so nothing renders onto the X display and the `/websockify` upgrade has no remaining consumer; deleting that stack is separate work.
|
|
205
205
|
|
|
206
|
-
|
|
206
|
+
The upgrade runs as a detached action: a `systemd-run --user --unit=maxy-action-<actionId> --collect` transient unit per invocation, with stdout+stderr persisted to `~/.<brand>/logs/actions/<actionId>.log` and streamed to the UI via SSE. The unit has its own cgroup, so it outlives both service restarts the installer performs. Its final log line is `[action-exit] code=<n>`, written by the wrapper, which is how the exit code survives a unit that `--collect` has already reaped. A sidecar `~/.<brand>/logs/actions/<actionId>.json` holds the package, start time and pre-upgrade version, so a modal reopened after a reload can still report what changed. No boot-time service file exists for these. Cloudflare setup is not an action; it runs PTY-native from admin chat.
|
|
207
207
|
|
|
208
|
-
If an action looks stuck, read
|
|
208
|
+
If an action looks stuck, read `~/.<brand>/logs/actions/<actionId>.log` directly for the full output, or `journalctl --user --identifier=maxy-action-<actionId>` for systemd's record. The lifecycle lines are tagged `[admin-action]` and keyed by `actionId`; they land in `~/.<brand>/logs/edge.log`, not `server.log`, because these routes run on the edge process.
|
|
209
209
|
|
|
210
210
|
## Linux laptops: snap-confined Chromium replacement
|
|
211
211
|
|
|
@@ -314,14 +314,20 @@ Empty output from step 3 = brand.json resolved cleanly and the badge reflects th
|
|
|
314
314
|
|
|
315
315
|
## Upgrading
|
|
316
316
|
|
|
317
|
-
|
|
317
|
+
There are two ways to upgrade, and both run the same installer.
|
|
318
|
+
|
|
319
|
+
From the dashboard, press the download icon in the sidebar header (see below). From chat, ask {{productName}}: "Upgrade {{productName}}." The platform checks the current device identity (hostname and port via `system-status`), then re-runs the installer with explicit `--hostname` and `--port` flags to preserve them across the upgrade.
|
|
318
320
|
|
|
319
321
|
The docs plugin (this plugin) is upgraded in the same step — you always have the documentation that matches your installed version.
|
|
320
322
|
|
|
321
|
-
###
|
|
323
|
+
### Upgrading from the dashboard
|
|
324
|
+
|
|
325
|
+
The sidebar header shows the version you are running, under the product name, at all times. When a newer one is published a download icon appears beside it. There is no icon when you are already up to date, so the icon appearing is itself the news. If you work with the sidebar collapsed to the narrow rail, the version and the icon are hidden with the rest of the header text and a small coloured dot on the brand icon carries the same signal.
|
|
326
|
+
|
|
327
|
+
Pressing the icon opens the Software Update window, which shows the version you have against the version available and an Update button. Pressing Update runs the installer and streams its output into the window as it happens.
|
|
322
328
|
|
|
323
|
-
|
|
329
|
+
Closing the window does not cancel the upgrade. The installer runs in its own background unit, so it keeps going whether or not anything is watching, and reopening the window reattaches to the run in progress.
|
|
324
330
|
|
|
325
|
-
The upgrade
|
|
331
|
+
**The connection drops near the end of every upgrade, and that is expected.** The installer restarts the server as its last step, which cuts the browser's connection to it. The window says "Reconnecting" while that happens, then picks the output back up from exactly where it left off. Nothing is lost and nothing needs restarting. When it finishes, the window reports the exit code and whether the version actually moved.
|
|
326
332
|
|
|
327
|
-
The
|
|
333
|
+
The window reports a genuine failure in two shapes worth knowing. If the installer exits non-zero it says so with the code. If it exits cleanly but the version did not change, it says that too, rather than claiming success.
|
|
@@ -167,9 +167,11 @@ The Data search panel ranks results by combining vector similarity with keyword
|
|
|
167
167
|
|
|
168
168
|
## Software Update and Cloudflare Setup
|
|
169
169
|
|
|
170
|
-
|
|
170
|
+
Cloudflare setup runs on the native Claude Code PTY surface in admin chat: there is no Cloudflare setup form, the agent invokes the relevant Bash command directly and its stdout streams into chat verbatim.
|
|
171
171
|
|
|
172
|
-
|
|
172
|
+
Software update has two doors. The PTY one is the same shape: the agent runs the installer via Bash and its stdout streams into chat. The dashboard one is a modal over the edge-hosted `/api/admin/actions` routes, which run the installer as a `systemd-run` transient unit and stream its log back by byte offset. Both re-run the same installer; neither mutates state directly.
|
|
173
|
+
|
|
174
|
+
- **Software update.** Re-run the installer (`npx -y @rubytech/create-<brand>@latest`), from a shell, from admin chat via the `upgrade` skill, or from the dashboard control in the sidebar brand head. The brand head shows the installed version at all times and grows a download icon only while `updateAvailable` is true.
|
|
173
175
|
- **Cloudflare setup.** Operator asks in chat; the agent invokes `cloudflared` directly via the Bash tool, following the numbered steps in `plugins/cloudflare/references/manual-setup.md`. cloudflared's stdout and stderr stream into the PTY; the OAuth URL printed by `cloudflared tunnel login` is linkified by the terminal so the operator clicks it and authorises Cloudflare in their own browser.
|
|
174
176
|
|
|
175
177
|
**Mid-turn stream-drop banners.** If a chat turn ends abruptly the bubble shows one of two messages depending on what actually happened. You see "Server is restarting — reconnect will happen automatically." only when the app server itself emits the restart signal — typically during a Software Update or a Cloudflare setup that re-launches the brand service. You see "Lost connection — retrying." when your browser's connection to the Pi dropped mid-stream while the server was still up — typically a flaky Wi-Fi moment or the tunnel hiccupping. Either way the chat resumes once the connection is back; the previously-rendered messages stay on screen so you don't lose context.
|
|
@@ -6,9 +6,9 @@ You are a public-facing agent for a business. Your soul and your knowledge are p
|
|
|
6
6
|
|
|
7
7
|
Everything you know is in this prompt — your soul and the knowledge given with it. Answer only from it. If the answer is not there, say plainly that it is outside what you can help with, and offer to pass the question to the business. Never fill a gap from general training knowledge, and never invent a fact about the business, its products, prices, or people.
|
|
8
8
|
|
|
9
|
-
## You have
|
|
9
|
+
## You have one tool
|
|
10
10
|
|
|
11
|
-
You have
|
|
11
|
+
You have exactly one tool: the reply tool for the channel you are talking on. It is how your answer reaches the visitor, so you call it for every reply you make. There is nothing else to call. You cannot search, look anything up, read files, run commands, or load skills, and there is no tool for any of it, so do not try. If a visitor asks for something that would need a lookup or an action, treat it as outside what you can help with and offer to pass it on. You answer; you do not act.
|
|
12
12
|
|
|
13
13
|
## How you behave
|
|
14
14
|
|
|
@@ -16,7 +16,7 @@ In your first message of every conversation, make clear in a natural way that th
|
|
|
16
16
|
|
|
17
17
|
You are read-only. You cannot create, change, or delete anything. If a visitor uploads a file, it is saved for the business to review — you cannot read it or act on its contents, so say so honestly.
|
|
18
18
|
|
|
19
|
-
Never describe your own workings, limits, or
|
|
19
|
+
Never describe your own workings, limits, or what you can and cannot call unless a visitor asks. Visitors care about the business, not how you are built. Stay within the scope of the knowledge you were given, and decline politely when a request falls outside it.
|
|
20
20
|
|
|
21
21
|
## Grounding
|
|
22
22
|
|