@rubytech/create-maxy-code 0.1.562 → 0.1.564
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 +38 -11
- package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +12 -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 +294 -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 +38 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +112 -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 +193 -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/plugins/docs/references/telegram-guide.md +19 -0
- package/payload/platform/plugins/memory/references/schema-construction.md +1 -1
- package/payload/platform/templates/agents/public/IDENTITY.md +3 -3
- package/payload/server/{chunk-G6ULACHG.js → chunk-3ABITJWB.js} +8 -4
- package/payload/server/{manager-2VEJKVEO.js → manager-N2RVJN7T.js} +1 -1
- 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-5gI137xF.js} +1 -1
- package/payload/server/public/assets/AdminLoginScreens-5gI137xF.js.br +0 -0
- package/payload/server/public/assets/AdminLoginScreens-5gI137xF.js.gz +0 -0
- package/payload/server/public/assets/AdminShell-Bfapavlp.js +3 -0
- package/payload/server/public/assets/AdminShell-Bfapavlp.js.br +0 -0
- package/payload/server/public/assets/AdminShell-Bfapavlp.js.gz +0 -0
- package/payload/server/public/assets/{activity-AVJSFESV.js → activity-DnbsmUgn.js} +1 -1
- package/payload/server/public/assets/activity-DnbsmUgn.js.br +0 -0
- package/payload/server/public/assets/activity-DnbsmUgn.js.gz +0 -0
- package/payload/server/public/assets/admin-BXIv0_oj.js +1 -0
- package/payload/server/public/assets/admin-BXIv0_oj.js.br +0 -0
- package/payload/server/public/assets/admin-BXIv0_oj.js.gz +0 -0
- package/payload/server/public/assets/{agents-CoWLZMf7.js → agents-Dl2wOcpj.js} +1 -1
- package/payload/server/public/assets/agents-Dl2wOcpj.js.br +0 -0
- package/payload/server/public/assets/agents-Dl2wOcpj.js.gz +0 -0
- package/payload/server/public/assets/{browser-C6K66_fw.js → browser-D-Uc9ewO.js} +1 -1
- package/payload/server/public/assets/browser-D-Uc9ewO.js.br +0 -0
- package/payload/server/public/assets/browser-D-Uc9ewO.js.gz +0 -0
- package/payload/server/public/assets/{calendar-Dnw_cP4b.js → calendar-Cu3O6Bay.js} +1 -1
- package/payload/server/public/assets/calendar-Cu3O6Bay.js.br +0 -0
- package/payload/server/public/assets/calendar-Cu3O6Bay.js.gz +0 -0
- package/payload/server/public/assets/chat-Bso94e-f.js +1 -0
- package/payload/server/public/assets/chat-Bso94e-f.js.br +0 -0
- package/payload/server/public/assets/chat-Bso94e-f.js.gz +0 -0
- package/payload/server/public/assets/chevron-left-CDwfR9V-.js +1 -0
- package/payload/server/public/assets/chevron-left-CDwfR9V-.js.br +0 -0
- package/payload/server/public/assets/clock-DDVrNLt9.js +1 -0
- package/payload/server/public/assets/clock-DDVrNLt9.js.br +0 -0
- package/payload/server/public/assets/clock-DDVrNLt9.js.gz +0 -0
- package/payload/server/public/assets/{copy-kuNM0Etw.js → copy-DPBVV8KZ.js} +1 -1
- package/payload/server/public/assets/copy-DPBVV8KZ.js.br +0 -0
- package/payload/server/public/assets/copy-DPBVV8KZ.js.gz +0 -0
- package/payload/server/public/assets/data-Dei8n2EX.js +1 -0
- package/payload/server/public/assets/data-Dei8n2EX.js.br +0 -0
- package/payload/server/public/assets/data-Dei8n2EX.js.gz +0 -0
- package/payload/server/public/assets/{file-text-BWkobrcx.js → file-text-Dn82O6ke.js} +1 -1
- package/payload/server/public/assets/file-text-Dn82O6ke.js.br +0 -0
- package/payload/server/public/assets/file-text-Dn82O6ke.js.gz +0 -0
- package/payload/server/public/assets/{graph-BGjTL5Vz.js → graph-D2J7a7Ll.js} +1 -1
- package/payload/server/public/assets/graph-D2J7a7Ll.js.br +0 -0
- package/payload/server/public/assets/graph-D2J7a7Ll.js.gz +0 -0
- package/payload/server/public/assets/{graph-labels-Dvc5Kw_r.js → graph-labels-D3gkY2IH.js} +1 -1
- package/payload/server/public/assets/graph-labels-D3gkY2IH.js.br +0 -0
- package/payload/server/public/assets/graph-labels-D3gkY2IH.js.gz +0 -0
- package/payload/server/public/assets/{operator-CzBGv_he.js → operator-Bz_yj1im.js} +1 -1
- package/payload/server/public/assets/operator-Bz_yj1im.js.br +0 -0
- package/payload/server/public/assets/operator-Bz_yj1im.js.gz +0 -0
- package/payload/server/public/assets/page-D6u2xYh7.js +1 -0
- package/payload/server/public/assets/page-D6u2xYh7.js.br +0 -0
- package/payload/server/public/assets/page-D6u2xYh7.js.gz +0 -0
- package/payload/server/public/assets/{page-BIY4Ku8Q.js → page-mH4nyQ75.js} +2 -2
- package/payload/server/public/assets/page-mH4nyQ75.js.br +0 -0
- package/payload/server/public/assets/page-mH4nyQ75.js.gz +0 -0
- package/payload/server/public/assets/play-ByuChQJJ.js +1 -0
- package/payload/server/public/assets/play-ByuChQJJ.js.br +0 -0
- package/payload/server/public/assets/play-ByuChQJJ.js.gz +0 -0
- package/payload/server/public/assets/{public-pwDSvhBv.js → public-CtoYVjPR.js} +1 -1
- package/payload/server/public/assets/public-CtoYVjPR.js.br +0 -0
- package/payload/server/public/assets/public-CtoYVjPR.js.gz +0 -0
- package/payload/server/public/assets/{rotate-ccw-B6s7tkEt.js → rotate-ccw-BXoIghE3.js} +1 -1
- package/payload/server/public/assets/rotate-ccw-BXoIghE3.js.br +0 -0
- package/payload/server/public/assets/rotate-ccw-BXoIghE3.js.gz +0 -0
- package/payload/server/public/assets/{routines-CFlyRdYg.js → routines-DXleYpDv.js} +1 -1
- package/payload/server/public/assets/routines-DXleYpDv.js.br +0 -0
- package/payload/server/public/assets/routines-DXleYpDv.js.gz +0 -0
- package/payload/server/public/assets/{skills-C45kfL_Q.js → skills-CMl38MKH.js} +1 -1
- package/payload/server/public/assets/skills-CMl38MKH.js.br +0 -0
- package/payload/server/public/assets/skills-CMl38MKH.js.gz +0 -0
- package/payload/server/public/assets/{tasks-BwoCTCu5.js → tasks-Cshaf0Md.js} +1 -1
- package/payload/server/public/assets/tasks-Cshaf0Md.js.br +0 -0
- package/payload/server/public/assets/tasks-Cshaf0Md.js.gz +0 -0
- package/payload/server/public/assets/{triangle-alert-B3bthSzx.js → triangle-alert-CS-NXr_9.js} +1 -1
- package/payload/server/public/assets/triangle-alert-CS-NXr_9.js.br +3 -0
- package/payload/server/public/assets/triangle-alert-CS-NXr_9.js.gz +0 -0
- package/payload/server/public/assets/{useCopyFeedback-BzJAkFEb.js → useCopyFeedback-pKLfQJNG.js} +1 -1
- package/payload/server/public/assets/useCopyFeedback-pKLfQJNG.js.br +0 -0
- package/payload/server/public/assets/useCopyFeedback-pKLfQJNG.js.gz +0 -0
- package/payload/server/public/assets/useMediaQuery-BHFn0-S9.css +1 -0
- package/payload/server/public/assets/useMediaQuery-BHFn0-S9.css.br +0 -0
- package/payload/server/public/assets/useMediaQuery-BHFn0-S9.css.gz +0 -0
- package/payload/server/public/assets/useMediaQuery-oDCasnxD.js +14 -0
- package/payload/server/public/assets/useMediaQuery-oDCasnxD.js.br +0 -0
- package/payload/server/public/assets/useMediaQuery-oDCasnxD.js.gz +0 -0
- package/payload/server/public/assets/useVoiceRecorder-EgO1GmIg.js +2 -0
- package/payload/server/public/assets/useVoiceRecorder-EgO1GmIg.js.br +0 -0
- package/payload/server/public/assets/useVoiceRecorder-EgO1GmIg.js.gz +0 -0
- package/payload/server/public/assets/{wrench-Bqux9jaw.js → wrench-czES9Rdy.js} +1 -1
- package/payload/server/public/assets/wrench-czES9Rdy.js.br +0 -0
- package/payload/server/public/assets/wrench-czES9Rdy.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 +2946 -1683
- 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 +0 -1
- 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 +0 -1
- 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 +0 -2
- 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
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
MAX_NOTE_LENGTH,
|
|
4
|
+
MAX_STATUS_LENGTH,
|
|
5
|
+
WORK_DATE_RE,
|
|
6
|
+
processVisitStatus,
|
|
7
|
+
} from '../../skills/data-portal/template/functions/api/visit-status'
|
|
8
|
+
import type {
|
|
9
|
+
D1Database,
|
|
10
|
+
PortalEnv,
|
|
11
|
+
} from '../../skills/data-portal/template/functions/api/_lib/types'
|
|
12
|
+
|
|
13
|
+
interface Row {
|
|
14
|
+
accountId: string
|
|
15
|
+
ownerId: string
|
|
16
|
+
visitId: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A fake that HONOURS the INSERT ... SELECT, because the gate IS the SELECT.
|
|
21
|
+
*
|
|
22
|
+
* It applies the same account and owner filters the statement names and reports
|
|
23
|
+
* `meta.changes` as the number of rows the SELECT admitted. A fake that always
|
|
24
|
+
* reported one change would pass a handler with no gate at all.
|
|
25
|
+
*/
|
|
26
|
+
function db(rows: Row[], person: { ownerId: string; recordScope: string; accountId: string }) {
|
|
27
|
+
const inserted: unknown[][] = []
|
|
28
|
+
const database = {
|
|
29
|
+
prepare(sql: string) {
|
|
30
|
+
let bound: unknown[] = []
|
|
31
|
+
const stmt = {
|
|
32
|
+
bind(...v: unknown[]) {
|
|
33
|
+
bound = v
|
|
34
|
+
return stmt
|
|
35
|
+
},
|
|
36
|
+
async first<T>() {
|
|
37
|
+
return person as unknown as T
|
|
38
|
+
},
|
|
39
|
+
async run() {
|
|
40
|
+
if (!sql.startsWith('INSERT INTO visit_status')) return { meta: { changes: 0 } }
|
|
41
|
+
// Bind order matches the statement: the SELECT's literals first, then
|
|
42
|
+
// the WHERE's account, the optional owner, and the visitId last.
|
|
43
|
+
const acct = bound[0] as string
|
|
44
|
+
const visitId = bound[bound.length - 1] as string
|
|
45
|
+
const owner = bound[1] as string
|
|
46
|
+
let admitted = rows.filter((r) => r.accountId === acct && r.visitId === visitId)
|
|
47
|
+
if (sql.includes('AND v.ownerId = ?')) admitted = admitted.filter((r) => r.ownerId === owner)
|
|
48
|
+
if (admitted.length) inserted.push(bound)
|
|
49
|
+
return { meta: { changes: admitted.length } }
|
|
50
|
+
},
|
|
51
|
+
async all<T>() {
|
|
52
|
+
return { results: [] as T[] }
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
return stmt
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
return { database: database as unknown as D1Database, inserted }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const env = (d: D1Database): PortalEnv =>
|
|
62
|
+
({ DB: d, BUCKET: {}, PORTAL_ACCOUNT_ID: 'acc-a' }) as unknown as PortalEnv
|
|
63
|
+
|
|
64
|
+
const ROWS: Row[] = [
|
|
65
|
+
{ accountId: 'acc-a', ownerId: 'alice', visitId: 'v1' },
|
|
66
|
+
{ accountId: 'acc-a', ownerId: 'bob', visitId: 'v2' },
|
|
67
|
+
{ accountId: 'acc-a', ownerId: '', visitId: 'v3' },
|
|
68
|
+
{ accountId: 'acc-b', ownerId: 'alice', visitId: 'v4' },
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
const person = (ownerId: string, recordScope: string) => ({
|
|
72
|
+
ownerId,
|
|
73
|
+
recordScope,
|
|
74
|
+
accountId: 'acc-a',
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const body = (visitId: string, status = 'on-site') => ({ visitId, status })
|
|
78
|
+
|
|
79
|
+
describe('the visit-status write gate', () => {
|
|
80
|
+
it('accepts an own-scoped person setting status on their own visit', async () => {
|
|
81
|
+
const { database, inserted } = db(ROWS, person('alice', 'own'))
|
|
82
|
+
const h = await processVisitStatus(body('v1'), 'sess', env(database), () => {}, 1)
|
|
83
|
+
expect(h.status).toBe(200)
|
|
84
|
+
expect(inserted).toHaveLength(1)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('refuses an own-scoped person setting status on someone else visit', async () => {
|
|
88
|
+
// A read leak shows someone a day that is not theirs. A write leak lets
|
|
89
|
+
// them change it, which is why the gate is the same one and is structural.
|
|
90
|
+
const { database, inserted } = db(ROWS, person('alice', 'own'))
|
|
91
|
+
const h = await processVisitStatus(body('v2'), 'sess', env(database), () => {}, 1)
|
|
92
|
+
expect(h.status).toBe(404)
|
|
93
|
+
expect(inserted).toEqual([])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('refuses an own-scoped person setting status on an unbound visit', async () => {
|
|
97
|
+
const { database, inserted } = db(ROWS, person('alice', 'own'))
|
|
98
|
+
const h = await processVisitStatus(body('v3'), 'sess', env(database), () => {}, 1)
|
|
99
|
+
expect(h.status).toBe(404)
|
|
100
|
+
expect(inserted).toEqual([])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('lets an account-scoped person set status on any visit for the account', async () => {
|
|
104
|
+
const { database, inserted } = db(ROWS, person('marie', 'account'))
|
|
105
|
+
const h = await processVisitStatus(body('v2'), 'sess', env(database), () => {}, 1)
|
|
106
|
+
expect(h.status).toBe(200)
|
|
107
|
+
expect(inserted).toHaveLength(1)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('refuses another account visit even at account scope', async () => {
|
|
111
|
+
const { database, inserted } = db(ROWS, person('marie', 'account'))
|
|
112
|
+
const h = await processVisitStatus(body('v4'), 'sess', env(database), () => {}, 1)
|
|
113
|
+
expect(h.status).toBe(404)
|
|
114
|
+
expect(inserted).toEqual([])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('denies when the session does not resolve', async () => {
|
|
118
|
+
const d = {
|
|
119
|
+
prepare: () => ({ bind: () => ({ first: async () => null }) }),
|
|
120
|
+
} as unknown as D1Database
|
|
121
|
+
const h = await processVisitStatus(body('v1'), '', env(d), () => {}, 1)
|
|
122
|
+
expect(h.status).toBe(401)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('refuses a body with no visitId or no status', async () => {
|
|
126
|
+
const { database, inserted } = db(ROWS, person('alice', 'own'))
|
|
127
|
+
const e = env(database)
|
|
128
|
+
expect((await processVisitStatus({ status: 'x' }, 'sess', e, () => {}, 1)).status).toBe(400)
|
|
129
|
+
expect((await processVisitStatus({ visitId: 'v1' }, 'sess', e, () => {}, 1)).status).toBe(400)
|
|
130
|
+
expect((await processVisitStatus(null, 'sess', e, () => {}, 1)).status).toBe(400)
|
|
131
|
+
expect(inserted).toEqual([])
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('refuses a status longer than the cap, rather than writing it into the graph', async () => {
|
|
135
|
+
// The value reaches :Visit.status through the pull. An unbounded free-text
|
|
136
|
+
// field written from the open web is refused at the door, not truncated:
|
|
137
|
+
// a truncated status is a different status, silently.
|
|
138
|
+
const { database, inserted } = db(ROWS, person('alice', 'own'))
|
|
139
|
+
const h = await processVisitStatus(
|
|
140
|
+
body('v1', 'x'.repeat(MAX_STATUS_LENGTH + 1)),
|
|
141
|
+
'sess',
|
|
142
|
+
env(database),
|
|
143
|
+
() => {},
|
|
144
|
+
1,
|
|
145
|
+
)
|
|
146
|
+
expect(h.status).toBe(400)
|
|
147
|
+
expect(inserted).toEqual([])
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('stamps the setter own id, never one the request supplies', async () => {
|
|
151
|
+
const { database, inserted } = db(ROWS, person('alice', 'own'))
|
|
152
|
+
await processVisitStatus(
|
|
153
|
+
{ visitId: 'v1', status: 'on-site', ownerId: 'bob' },
|
|
154
|
+
'sess',
|
|
155
|
+
env(database),
|
|
156
|
+
() => {},
|
|
157
|
+
1,
|
|
158
|
+
)
|
|
159
|
+
expect(inserted[0]).toContain('alice')
|
|
160
|
+
expect(inserted[0]).not.toContain('bob')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('logs the scope and the outcome, and no status text', async () => {
|
|
164
|
+
const lines: string[] = []
|
|
165
|
+
const { database } = db(ROWS, person('alice', 'own'))
|
|
166
|
+
await processVisitStatus(
|
|
167
|
+
body('v1', 'on-site-at-granary-court'),
|
|
168
|
+
'sess',
|
|
169
|
+
env(database),
|
|
170
|
+
(l) => lines.push(l),
|
|
171
|
+
1,
|
|
172
|
+
)
|
|
173
|
+
const joined = lines.join('\n')
|
|
174
|
+
expect(joined).toContain('scope=own')
|
|
175
|
+
expect(joined).toContain('result=ok')
|
|
176
|
+
expect(joined).not.toContain('granary')
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
describe('the working day a status edit may carry', () => {
|
|
181
|
+
const day = (extra: Record<string, unknown>) => ({ visitId: 'v1', status: 'done', ...extra })
|
|
182
|
+
|
|
183
|
+
it('inserts the day, the date and the note when the claim is well formed', async () => {
|
|
184
|
+
const { database, inserted } = db(ROWS, person('alice', 'own'))
|
|
185
|
+
const h = await processVisitStatus(
|
|
186
|
+
day({ dayHundredths: 100, workDate: '2026-08-05', workNote: 'second fix' }),
|
|
187
|
+
'sess',
|
|
188
|
+
env(database),
|
|
189
|
+
() => {},
|
|
190
|
+
1,
|
|
191
|
+
)
|
|
192
|
+
expect(h.status).toBe(200)
|
|
193
|
+
expect(inserted[0]).toContain(100)
|
|
194
|
+
expect(inserted[0]).toContain('2026-08-05')
|
|
195
|
+
expect(inserted[0]).toContain('second fix')
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('defaults to no claim when the body carries no day', async () => {
|
|
199
|
+
// A status-only edit is the common case and must stay a one-field write.
|
|
200
|
+
const { database, inserted } = db(ROWS, person('alice', 'own'))
|
|
201
|
+
const h = await processVisitStatus(day({}), 'sess', env(database), () => {}, 1)
|
|
202
|
+
expect(h.status).toBe(200)
|
|
203
|
+
expect(inserted[0]).toContain(0)
|
|
204
|
+
expect(inserted[0]).toContain('')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('refuses a day outside 0..100 rather than writing it into the graph', async () => {
|
|
208
|
+
// A value above a full day is described by no shipped surface. Refused at
|
|
209
|
+
// the door, exactly as an over-long status is.
|
|
210
|
+
const { database, inserted } = db(ROWS, person('alice', 'own'))
|
|
211
|
+
const e = env(database)
|
|
212
|
+
for (const d of [101, -1, 50.5, '100']) {
|
|
213
|
+
const h = await processVisitStatus(
|
|
214
|
+
day({ dayHundredths: d, workDate: '2026-08-05' }),
|
|
215
|
+
'sess',
|
|
216
|
+
e,
|
|
217
|
+
() => {},
|
|
218
|
+
1,
|
|
219
|
+
)
|
|
220
|
+
expect(h.status).toBe(400)
|
|
221
|
+
}
|
|
222
|
+
expect(inserted).toEqual([])
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('refuses a claim whose date is missing or malformed', async () => {
|
|
226
|
+
// The date becomes part of the cost key. A claim the roll-up cannot date is
|
|
227
|
+
// told to the person now, not left sitting in the queue.
|
|
228
|
+
const { database, inserted } = db(ROWS, person('alice', 'own'))
|
|
229
|
+
const e = env(database)
|
|
230
|
+
for (const wd of [undefined, '', '5th Aug', '2026-8-5']) {
|
|
231
|
+
const h = await processVisitStatus(
|
|
232
|
+
day({ dayHundredths: 100, workDate: wd }),
|
|
233
|
+
'sess',
|
|
234
|
+
e,
|
|
235
|
+
() => {},
|
|
236
|
+
1,
|
|
237
|
+
)
|
|
238
|
+
expect(h.status).toBe(400)
|
|
239
|
+
}
|
|
240
|
+
expect(inserted).toEqual([])
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('accepts a date only in the shape the pull will key on', () => {
|
|
244
|
+
expect(WORK_DATE_RE.test('2026-08-05')).toBe(true)
|
|
245
|
+
expect(WORK_DATE_RE.test('2026-08-05T09:00:00Z')).toBe(false)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('refuses a note longer than the cap rather than truncating it', async () => {
|
|
249
|
+
const { database, inserted } = db(ROWS, person('alice', 'own'))
|
|
250
|
+
const h = await processVisitStatus(
|
|
251
|
+
day({
|
|
252
|
+
dayHundredths: 100,
|
|
253
|
+
workDate: '2026-08-05',
|
|
254
|
+
workNote: 'x'.repeat(MAX_NOTE_LENGTH + 1),
|
|
255
|
+
}),
|
|
256
|
+
'sess',
|
|
257
|
+
env(database),
|
|
258
|
+
() => {},
|
|
259
|
+
1,
|
|
260
|
+
)
|
|
261
|
+
expect(h.status).toBe(400)
|
|
262
|
+
expect(inserted).toEqual([])
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('logs whether the edit carried a day, and never the note', async () => {
|
|
266
|
+
const lines: string[] = []
|
|
267
|
+
const { database } = db(ROWS, person('alice', 'own'))
|
|
268
|
+
await processVisitStatus(
|
|
269
|
+
day({ dayHundredths: 100, workDate: '2026-08-05', workNote: 'granary court rewire' }),
|
|
270
|
+
'sess',
|
|
271
|
+
env(database),
|
|
272
|
+
(l) => lines.push(l),
|
|
273
|
+
1,
|
|
274
|
+
)
|
|
275
|
+
const joined = lines.join('\n')
|
|
276
|
+
expect(joined).toContain('labour=true')
|
|
277
|
+
expect(joined).not.toContain('granary')
|
|
278
|
+
expect(joined).not.toContain('2026-08-05')
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('still refuses another person visit when the edit carries a day', async () => {
|
|
282
|
+
// The gate is the SELECT and the new fields must not have moved it.
|
|
283
|
+
const { database, inserted } = db(ROWS, person('alice', 'own'))
|
|
284
|
+
const h = await processVisitStatus(
|
|
285
|
+
{ visitId: 'v2', status: 'done', dayHundredths: 100, workDate: '2026-08-05' },
|
|
286
|
+
'sess',
|
|
287
|
+
env(database),
|
|
288
|
+
() => {},
|
|
289
|
+
1,
|
|
290
|
+
)
|
|
291
|
+
expect(h.status).toBe(404)
|
|
292
|
+
expect(inserted).toEqual([])
|
|
293
|
+
})
|
|
294
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { processVisits } from '../../skills/data-portal/template/functions/api/visits'
|
|
3
|
+
import type {
|
|
4
|
+
D1Database,
|
|
5
|
+
PortalEnv,
|
|
6
|
+
} from '../../skills/data-portal/template/functions/api/_lib/types'
|
|
7
|
+
|
|
8
|
+
interface Row {
|
|
9
|
+
accountId: string
|
|
10
|
+
ownerId: string
|
|
11
|
+
visitId: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A fake that HONOURS the SQL it is handed rather than replaying a fixture.
|
|
16
|
+
*
|
|
17
|
+
* It reads the WHERE clause and the bound values and filters accordingly, so
|
|
18
|
+
* deleting the scope branch in the endpoint changes what comes back. A fake
|
|
19
|
+
* that replayed rows would pass whatever the gate did, which is the same defect
|
|
20
|
+
* shape as 1689's upload fake accepting duplicates the schema rejected.
|
|
21
|
+
*/
|
|
22
|
+
function db(rows: Row[], person: { ownerId: string; recordScope: string; accountId: string }) {
|
|
23
|
+
return {
|
|
24
|
+
prepare(sql: string) {
|
|
25
|
+
let bound: unknown[] = []
|
|
26
|
+
const stmt = {
|
|
27
|
+
bind(...v: unknown[]) {
|
|
28
|
+
bound = v
|
|
29
|
+
return stmt
|
|
30
|
+
},
|
|
31
|
+
async first<T>() {
|
|
32
|
+
return person as unknown as T
|
|
33
|
+
},
|
|
34
|
+
async run() {
|
|
35
|
+
return { meta: { changes: 0 } }
|
|
36
|
+
},
|
|
37
|
+
async all<T>() {
|
|
38
|
+
if (!sql.includes('FROM visits')) return { results: [] as T[] }
|
|
39
|
+
const [acct, owner] = bound as string[]
|
|
40
|
+
let out = rows.filter((r) => r.accountId === acct)
|
|
41
|
+
if (sql.includes('v.ownerId = ?')) out = out.filter((r) => r.ownerId === owner)
|
|
42
|
+
return { results: out as unknown as T[] }
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
return stmt
|
|
46
|
+
},
|
|
47
|
+
} as unknown as D1Database
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const env = (d: D1Database): PortalEnv =>
|
|
51
|
+
({ DB: d, BUCKET: {}, PORTAL_ACCOUNT_ID: 'acc-a' }) as unknown as PortalEnv
|
|
52
|
+
|
|
53
|
+
const ROWS: Row[] = [
|
|
54
|
+
{ accountId: 'acc-a', ownerId: 'alice', visitId: 'v1' },
|
|
55
|
+
{ accountId: 'acc-a', ownerId: 'bob', visitId: 'v2' },
|
|
56
|
+
{ accountId: 'acc-a', ownerId: '', visitId: 'v3' },
|
|
57
|
+
{ accountId: 'acc-b', ownerId: 'alice', visitId: 'v4' },
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
const ids = (h: { payload: Record<string, unknown> }) =>
|
|
61
|
+
(h.payload.visits as Row[]).map((v) => v.visitId).sort()
|
|
62
|
+
|
|
63
|
+
const asPerson = (ownerId: string, recordScope: string) => ({
|
|
64
|
+
ownerId,
|
|
65
|
+
recordScope,
|
|
66
|
+
accountId: 'acc-a',
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('the visits scope gate', () => {
|
|
70
|
+
it('shows an own-scoped person only their own visits', async () => {
|
|
71
|
+
const h = await processVisits('sess', env(db(ROWS, asPerson('alice', 'own'))), () => {}, 1)
|
|
72
|
+
expect(ids(h)).toEqual(['v1'])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('shows an account-scoped person every visit for the account', async () => {
|
|
76
|
+
const h = await processVisits('sess', env(db(ROWS, asPerson('marie', 'account'))), () => {}, 1)
|
|
77
|
+
expect(ids(h)).toEqual(['v1', 'v2', 'v3'])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('never returns another account rows, at either scope', async () => {
|
|
81
|
+
// The tenant filter is the baked-in var, not the session, and it is applied
|
|
82
|
+
// whichever scope the person holds. `v4` belongs to acc-b.
|
|
83
|
+
const own = await processVisits('sess', env(db(ROWS, asPerson('alice', 'own'))), () => {}, 1)
|
|
84
|
+
const acct = await processVisits('sess', env(db(ROWS, asPerson('marie', 'account'))), () => {}, 1)
|
|
85
|
+
expect(ids(own)).not.toContain('v4')
|
|
86
|
+
expect(ids(acct)).not.toContain('v4')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('hides an unbound visit from an own-scoped person', async () => {
|
|
90
|
+
// An unbound visit carries ownerId ''. Enrolment pins ownerId to
|
|
91
|
+
// ^[a-z0-9-]+$ (portal-enrol.mjs), so no enrolled person's id is ever '',
|
|
92
|
+
// and the own-scoped filter cannot match the unbound sentinel.
|
|
93
|
+
const h = await processVisits('sess', env(db(ROWS, asPerson('alice', 'own'))), () => {}, 1)
|
|
94
|
+
expect(ids(h)).not.toContain('v3')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('denies when the session does not resolve', async () => {
|
|
98
|
+
const d = {
|
|
99
|
+
prepare: () => ({ bind: () => ({ first: async () => null }) }),
|
|
100
|
+
} as unknown as D1Database
|
|
101
|
+
const h = await processVisits('', env(d), () => {}, 1)
|
|
102
|
+
expect(h.status).toBe(401)
|
|
103
|
+
expect(h.payload).toEqual({ ok: false, error: 'denied' })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('logs the scope it applied, and no visit detail', async () => {
|
|
107
|
+
const lines: string[] = []
|
|
108
|
+
await processVisits('sess', env(db(ROWS, asPerson('alice', 'own'))), (l) => lines.push(l), 1)
|
|
109
|
+
expect(lines.join('\n')).toContain('scope=own')
|
|
110
|
+
expect(lines.join('\n')).toContain('visits=1')
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -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,43 @@ 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
|
+
|
|
150
|
+
**A status edit may also carry a working day, and a store that predates those three columns needs
|
|
151
|
+
them added once too.** `dayHundredths` is integer hundredths of a day, which is what the timesheet
|
|
152
|
+
engine consumes: 100 is a full day, and 0 means the edit claims no labour. `workDate` is the day
|
|
153
|
+
worked, and the pull refuses a claim without one rather than dating it from when the sheet was filed.
|
|
154
|
+
`workNote` is what the worker did. The same `IF NOT EXISTS` rule applies, so run all three:
|
|
155
|
+
|
|
156
|
+
```sql
|
|
157
|
+
ALTER TABLE visit_status ADD COLUMN dayHundredths INTEGER NOT NULL DEFAULT 0;
|
|
158
|
+
ALTER TABLE visit_status ADD COLUMN workDate TEXT NOT NULL DEFAULT '';
|
|
159
|
+
ALTER TABLE visit_status ADD COLUMN workNote TEXT NOT NULL DEFAULT '';
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Until they exist the endpoint's INSERT names columns the table lacks and fails loudly, which is the
|
|
163
|
+
intended outcome: a status edit silently losing its hours is the failure worth avoiding.
|
|
164
|
+
|
|
127
165
|
**A passcode is never stored in plaintext** — not in `data-portal.json`, not in a log line, nowhere
|
|
128
166
|
on the device. The operator conveys it to the person out of band. Rotating a passcode is a re-run of
|
|
129
167
|
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,98 @@ 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
|
+
-- `dayHundredths`, `workDate` and `workNote` are the working day the edit
|
|
313
|
+
-- carries (Task 2432). `dayHundredths` is integer hundredths of a day, which is
|
|
314
|
+
-- exactly what the timesheet engine consumes: 100 is a full day. 0 means this
|
|
315
|
+
-- edit carries no labour claim, the same absent-sentinel convention `ownerId`
|
|
316
|
+
-- uses above for unbound. The pull writes a labour :JobCost only when it is
|
|
317
|
+
-- above 0, and refuses a claim carrying no `workDate` rather than dating it
|
|
318
|
+
-- from anything else: `statusAt` would record the wrong day for a sheet filed
|
|
319
|
+
-- the next morning, and a visit's scheduled date is not evidence of when it was
|
|
320
|
+
-- attended.
|
|
321
|
+
--
|
|
322
|
+
-- IF NOT EXISTS adds none of the three to a table that predates them. Add them
|
|
323
|
+
-- by hand in that case, in this order:
|
|
324
|
+
-- ALTER TABLE visit_status ADD COLUMN dayHundredths INTEGER NOT NULL DEFAULT 0;
|
|
325
|
+
-- ALTER TABLE visit_status ADD COLUMN workDate TEXT NOT NULL DEFAULT '';
|
|
326
|
+
-- ALTER TABLE visit_status ADD COLUMN workNote TEXT NOT NULL DEFAULT '';
|
|
327
|
+
CREATE TABLE IF NOT EXISTS visit_status (
|
|
328
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
329
|
+
accountId TEXT NOT NULL,
|
|
330
|
+
visitId TEXT NOT NULL,
|
|
331
|
+
ownerId TEXT NOT NULL,
|
|
332
|
+
status TEXT NOT NULL,
|
|
333
|
+
statusAt TEXT NOT NULL,
|
|
334
|
+
dayHundredths INTEGER NOT NULL DEFAULT 0,
|
|
335
|
+
workDate TEXT NOT NULL DEFAULT '',
|
|
336
|
+
workNote TEXT NOT NULL DEFAULT '',
|
|
337
|
+
claimed INTEGER NOT NULL DEFAULT 0
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
-- The pull selects unclaimed rows for one account every sixty seconds, so the
|
|
341
|
+
-- pair carries its own index or every cycle is a full scan.
|
|
342
|
+
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
|