@rubytech/create-sitedesk-code 0.1.519 → 0.1.521
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/docs/superpowers/plans/2026-07-28-task-2097-email-signature-set.md +65 -0
- package/payload/platform/docs/superpowers/specs/2026-07-28-task-2097-email-signature-set-design.md +155 -0
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +4 -2
- package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +12 -0
- package/payload/platform/plugins/cloudflare/bin/portal-assemble.mjs +187 -0
- package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +46 -5
- package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-route.test.ts +19 -7
- package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +154 -38
- package/payload/platform/plugins/cloudflare/mcp/__tests__/file-routes.test.ts +100 -29
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-assemble.test.ts +143 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-delete-withdraw.test.ts +12 -5
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +15 -3
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +105 -11
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +15 -2
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-moved-download.test.ts +9 -5
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-schema-shape.test.ts +47 -5
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-session-grant.test.ts +26 -2
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-target.test.ts +12 -4
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-upload-multipart.test.ts +23 -15
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-upload-target.test.ts +23 -15
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-uploads-listing.test.ts +8 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/skill-contract.test.ts +23 -6
- package/payload/platform/plugins/cloudflare/mcp/__tests__/upload-route.test.ts +36 -20
- package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +85 -22
- package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +47 -7
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/authorize.ts +27 -18
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/ratelimit.ts +10 -3
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +27 -11
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/target.ts +18 -9
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts +16 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/auth.ts +16 -4
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/delete.ts +26 -11
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +14 -10
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +13 -9
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/upload.ts +42 -22
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +17 -4
- package/payload/platform/plugins/docs/references/admin-ui.md +1 -1
- package/payload/platform/plugins/email/.claude-plugin/plugin.json +1 -1
- package/payload/platform/plugins/email/PLUGIN.md +20 -1
- package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-metadata-calendar.test.js +46 -1
- package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-metadata-calendar.test.js.map +1 -1
- package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list-tool.test.d.ts +2 -0
- package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list-tool.test.d.ts.map +1 -0
- package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list-tool.test.js +142 -0
- package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list-tool.test.js.map +1 -0
- package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list.test.d.ts +2 -0
- package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list.test.d.ts.map +1 -0
- package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list.test.js +153 -0
- package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-list.test.js.map +1 -0
- package/payload/platform/plugins/email/mcp/dist/__tests__/email-signature-set.test.d.ts +2 -0
- package/payload/platform/plugins/email/mcp/dist/__tests__/email-signature-set.test.d.ts.map +1 -0
- package/payload/platform/plugins/email/mcp/dist/__tests__/email-signature-set.test.js +203 -0
- package/payload/platform/plugins/email/mcp/dist/__tests__/email-signature-set.test.js.map +1 -0
- package/payload/platform/plugins/email/mcp/dist/index.js +69 -0
- package/payload/platform/plugins/email/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/email/mcp/dist/lib/compose.d.ts.map +1 -1
- package/payload/platform/plugins/email/mcp/dist/lib/compose.js +5 -7
- package/payload/platform/plugins/email/mcp/dist/lib/compose.js.map +1 -1
- package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts +56 -0
- package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts.map +1 -1
- package/payload/platform/plugins/email/mcp/dist/lib/imap.js +111 -4
- package/payload/platform/plugins/email/mcp/dist/lib/imap.js.map +1 -1
- package/payload/platform/plugins/email/mcp/dist/lib/signature.d.ts +38 -0
- package/payload/platform/plugins/email/mcp/dist/lib/signature.d.ts.map +1 -1
- package/payload/platform/plugins/email/mcp/dist/lib/signature.js +67 -2
- package/payload/platform/plugins/email/mcp/dist/lib/signature.js.map +1 -1
- package/payload/platform/plugins/email/mcp/dist/tools/email-draft-list.d.ts +10 -0
- package/payload/platform/plugins/email/mcp/dist/tools/email-draft-list.d.ts.map +1 -0
- package/payload/platform/plugins/email/mcp/dist/tools/email-draft-list.js +73 -0
- package/payload/platform/plugins/email/mcp/dist/tools/email-draft-list.js.map +1 -0
- package/payload/platform/plugins/email/mcp/dist/tools/email-signature-set.d.ts +18 -0
- package/payload/platform/plugins/email/mcp/dist/tools/email-signature-set.d.ts.map +1 -0
- package/payload/platform/plugins/email/mcp/dist/tools/email-signature-set.js +38 -0
- package/payload/platform/plugins/email/mcp/dist/tools/email-signature-set.js.map +1 -0
- package/payload/platform/plugins/email/references/email-reference.md +15 -3
- package/payload/platform/plugins/email/skills/email-composition/SKILL.md +10 -0
- package/payload/platform/plugins/email/skills/email-signature/SKILL.md +61 -0
- package/payload/platform/plugins/scheduling/PLUGIN.md +8 -0
- package/payload/platform/plugins/scheduling/mcp/dist/index.js +26 -15
- package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/schedule-audit.test.d.ts +2 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/schedule-audit.test.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/schedule-audit.test.js +48 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/schedule-audit.test.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.d.ts +24 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.js +33 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js +22 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-cancel-meeting.test.d.ts +2 -0
- package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-cancel-meeting.test.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-cancel-meeting.test.js +30 -0
- package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-cancel-meeting.test.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-get-meeting.test.d.ts +2 -0
- package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-get-meeting.test.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-get-meeting.test.js +43 -0
- package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-get-meeting.test.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-list.test.d.ts +2 -0
- package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-list.test.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-list.test.js +87 -0
- package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-list.test.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-update-meeting.test.d.ts +2 -0
- package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-update-meeting.test.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-update-meeting.test.js +48 -0
- package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-update-meeting.test.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-cancel.d.ts.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-cancel.js +14 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-cancel.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-get.d.ts +4 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-get.d.ts.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-get.js +39 -2
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-get.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-list.d.ts +4 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-list.d.ts.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-list.js +50 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-list.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.d.ts.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.js +43 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.js.map +1 -1
- package/payload/platform/plugins/whatsapp/references/channels-whatsapp.md +3 -1
- package/payload/platform/scripts/check-architecture-skill-no-drift.mjs +20 -6
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +2 -0
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +17 -5
- package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/notification.js +30 -6
- package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/server.d.ts.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/server.js +53 -21
- package/payload/platform/services/whatsapp-channel/dist/server.js.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/turn-follow.d.ts +16 -0
- package/payload/platform/services/whatsapp-channel/dist/turn-follow.d.ts.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/turn-follow.js +41 -2
- package/payload/platform/services/whatsapp-channel/dist/turn-follow.js.map +1 -1
- package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
- package/payload/server/{chunk-TIIWMF5G.js → chunk-AZLT4LVO.js} +40 -7
- package/payload/server/{manager-B6OFOXSW.js → manager-CQBSUJXR.js} +1 -1
- package/payload/server/public/activity.html +5 -5
- package/payload/server/public/agents.html +4 -4
- package/payload/server/public/assets/AdminLoginScreens-Dar_MMJm.js +1 -0
- package/payload/server/public/assets/AdminLoginScreens-Dar_MMJm.js.br +0 -0
- package/payload/server/public/assets/AdminLoginScreens-Dar_MMJm.js.gz +0 -0
- package/payload/server/public/assets/AdminShell-CpaI2F_p.js +2 -0
- package/payload/server/public/assets/AdminShell-CpaI2F_p.js.br +0 -0
- package/payload/server/public/assets/AdminShell-CpaI2F_p.js.gz +0 -0
- package/payload/server/public/assets/{activity-DOsfKzT3.js → activity-B1rt4GIy.js} +1 -1
- package/payload/server/public/assets/activity-B1rt4GIy.js.br +0 -0
- package/payload/server/public/assets/activity-B1rt4GIy.js.gz +0 -0
- package/payload/server/public/assets/admin-DE3lLNje.js +1 -0
- package/payload/server/public/assets/admin-DE3lLNje.js.br +0 -0
- package/payload/server/public/assets/admin-DE3lLNje.js.gz +0 -0
- package/payload/server/public/assets/{agents-DqDnZTH_.js → agents-BXq8woVf.js} +1 -1
- package/payload/server/public/assets/agents-BXq8woVf.js.br +0 -0
- package/payload/server/public/assets/agents-BXq8woVf.js.gz +0 -0
- package/payload/server/public/assets/{browser-FL17Dfp0.js → browser-1QSCPl3X.js} +1 -1
- package/payload/server/public/assets/browser-1QSCPl3X.js.br +0 -0
- package/payload/server/public/assets/browser-1QSCPl3X.js.gz +0 -0
- package/payload/server/public/assets/{calendar-CLfLvsnV.js → calendar-D009qWVV.js} +1 -1
- package/payload/server/public/assets/calendar-D009qWVV.js.br +0 -0
- package/payload/server/public/assets/calendar-D009qWVV.js.gz +0 -0
- package/payload/server/public/assets/chat-CUg1fTqJ.js +1 -0
- package/payload/server/public/assets/chat-CUg1fTqJ.js.br +1 -0
- package/payload/server/public/assets/chat-CUg1fTqJ.js.gz +0 -0
- package/payload/server/public/assets/chevron-left-C2JEy7uU.js +1 -0
- package/payload/server/public/assets/chevron-left-C2JEy7uU.js.br +0 -0
- package/payload/server/public/assets/chevron-right-DWa047Ys.js +1 -0
- package/payload/server/public/assets/chevron-right-DWa047Ys.js.br +0 -0
- package/payload/server/public/assets/clock-C3OqfCnn.js +1 -0
- package/payload/server/public/assets/clock-C3OqfCnn.js.br +0 -0
- package/payload/server/public/assets/clock-C3OqfCnn.js.gz +0 -0
- package/payload/server/public/assets/data-Bu_yCScC.js +1 -0
- package/payload/server/public/assets/data-Bu_yCScC.js.br +0 -0
- package/payload/server/public/assets/data-Bu_yCScC.js.gz +0 -0
- package/payload/server/public/assets/{file-text-DNm0AzUz.js → file-text-DwGAfCsd.js} +1 -1
- package/payload/server/public/assets/file-text-DwGAfCsd.js.br +0 -0
- package/payload/server/public/assets/file-text-DwGAfCsd.js.gz +0 -0
- package/payload/server/public/assets/{graph-fAvmlVzh.js → graph-D-DGlfhW.js} +2 -2
- package/payload/server/public/assets/graph-D-DGlfhW.js.br +0 -0
- package/payload/server/public/assets/graph-D-DGlfhW.js.gz +0 -0
- package/payload/server/public/assets/{graph-labels-CwdkCxfs.js → graph-labels-BbX3fXzl.js} +1 -1
- package/payload/server/public/assets/graph-labels-BbX3fXzl.js.br +0 -0
- package/payload/server/public/assets/graph-labels-BbX3fXzl.js.gz +0 -0
- package/payload/server/public/assets/{maximize-2-B6tAPhC2.js → maximize-2-C_-KapxH.js} +1 -1
- package/payload/server/public/assets/maximize-2-C_-KapxH.js.br +0 -0
- package/payload/server/public/assets/maximize-2-C_-KapxH.js.gz +0 -0
- package/payload/server/public/assets/{operator-CTKvBVjk.js → operator-CFrZVcv6.js} +1 -1
- package/payload/server/public/assets/operator-CFrZVcv6.js.br +0 -0
- package/payload/server/public/assets/operator-CFrZVcv6.js.gz +0 -0
- package/payload/server/public/assets/{page-O5HkHQw4.js → page-CHt62f1_.js} +1 -1
- package/payload/server/public/assets/page-CHt62f1_.js.br +0 -0
- package/payload/server/public/assets/page-CHt62f1_.js.gz +0 -0
- package/payload/server/public/assets/page-E74MYB18.js +32 -0
- package/payload/server/public/assets/page-E74MYB18.js.br +0 -0
- package/payload/server/public/assets/page-E74MYB18.js.gz +0 -0
- package/payload/server/public/assets/{public-BF1rnVPa.js → public-CI6K1sNW.js} +1 -1
- package/payload/server/public/assets/public-CI6K1sNW.js.br +0 -0
- package/payload/server/public/assets/public-CI6K1sNW.js.gz +0 -0
- package/payload/server/public/assets/{rotate-ccw-B8TqfCy4.js → rotate-ccw-PWu_QGrD.js} +1 -1
- package/payload/server/public/assets/rotate-ccw-PWu_QGrD.js.br +0 -0
- package/payload/server/public/assets/rotate-ccw-PWu_QGrD.js.gz +0 -0
- package/payload/server/public/assets/routines-Cp3_XS7S.js +1 -0
- package/payload/server/public/assets/routines-Cp3_XS7S.js.br +0 -0
- package/payload/server/public/assets/routines-Cp3_XS7S.js.gz +0 -0
- package/payload/server/public/assets/skills-g8HvunPA.js +1 -0
- package/payload/server/public/assets/skills-g8HvunPA.js.br +0 -0
- package/payload/server/public/assets/skills-g8HvunPA.js.gz +0 -0
- package/payload/server/public/assets/{tasks-DePyOrRk.js → tasks-CMC_jDVm.js} +1 -1
- package/payload/server/public/assets/tasks-CMC_jDVm.js.br +0 -0
- package/payload/server/public/assets/tasks-CMC_jDVm.js.gz +0 -0
- package/payload/server/public/assets/{time-entry-format-D9gtnG01.js → time-entry-format-BXWuQ6Sw.js} +1 -1
- package/payload/server/public/assets/time-entry-format-BXWuQ6Sw.js.br +2 -0
- package/payload/server/public/assets/time-entry-format-BXWuQ6Sw.js.gz +0 -0
- package/payload/server/public/assets/{triangle-alert-DAtm2mLL.js → triangle-alert-BNshRC13.js} +1 -1
- package/payload/server/public/assets/triangle-alert-BNshRC13.js.br +0 -0
- package/payload/server/public/assets/triangle-alert-BNshRC13.js.gz +0 -0
- package/payload/server/public/assets/{useCopyFeedback-uMJT-p6i.js → useCopyFeedback-CwTZ9DqH.js} +1 -1
- package/payload/server/public/assets/useCopyFeedback-CwTZ9DqH.js.br +0 -0
- package/payload/server/public/assets/useCopyFeedback-CwTZ9DqH.js.gz +0 -0
- package/payload/server/public/assets/useSubAccountSwitcher-BuUYMDWl.js +14 -0
- package/payload/server/public/assets/useSubAccountSwitcher-BuUYMDWl.js.br +0 -0
- package/payload/server/public/assets/useSubAccountSwitcher-BuUYMDWl.js.gz +0 -0
- package/payload/server/public/assets/useSubAccountSwitcher-dzDnmWq9.css +1 -0
- package/payload/server/public/assets/useSubAccountSwitcher-dzDnmWq9.css.br +0 -0
- package/payload/server/public/assets/useSubAccountSwitcher-dzDnmWq9.css.gz +0 -0
- package/payload/server/public/assets/{useVoiceRecorder-BFrPC-s7.js → useVoiceRecorder-BGqV5R6u.js} +1 -1
- package/payload/server/public/assets/useVoiceRecorder-BGqV5R6u.js.br +0 -0
- package/payload/server/public/assets/useVoiceRecorder-BGqV5R6u.js.gz +0 -0
- package/payload/server/public/assets/{wrench-CYgaN7K9.js → wrench-DQ045Pyk.js} +1 -1
- package/payload/server/public/assets/wrench-DQ045Pyk.js.br +0 -0
- package/payload/server/public/assets/wrench-DQ045Pyk.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 +14 -14
- package/payload/server/public/operator.html +14 -14
- package/payload/server/public/public.html +13 -13
- package/payload/server/public/routines.html +6 -6
- package/payload/server/public/skills.html +5 -5
- package/payload/server/public/tasks.html +6 -6
- package/payload/server/server.js +687 -400
- package/payload/server/public/assets/AdminLoginScreens-BZPbCk8g.js +0 -1
- package/payload/server/public/assets/AdminLoginScreens-BZPbCk8g.js.br +0 -0
- package/payload/server/public/assets/AdminLoginScreens-BZPbCk8g.js.gz +0 -0
- package/payload/server/public/assets/AdminShell-Bpo_wgxm.js +0 -2
- package/payload/server/public/assets/AdminShell-Bpo_wgxm.js.br +0 -0
- package/payload/server/public/assets/AdminShell-Bpo_wgxm.js.gz +0 -0
- package/payload/server/public/assets/activity-DOsfKzT3.js.br +0 -0
- package/payload/server/public/assets/activity-DOsfKzT3.js.gz +0 -0
- package/payload/server/public/assets/admin-COgD1z5a.js +0 -1
- package/payload/server/public/assets/admin-COgD1z5a.js.br +0 -0
- package/payload/server/public/assets/admin-COgD1z5a.js.gz +0 -0
- package/payload/server/public/assets/agents-DqDnZTH_.js.br +0 -0
- package/payload/server/public/assets/agents-DqDnZTH_.js.gz +0 -0
- package/payload/server/public/assets/browser-FL17Dfp0.js.br +0 -0
- package/payload/server/public/assets/browser-FL17Dfp0.js.gz +0 -0
- package/payload/server/public/assets/calendar-CLfLvsnV.js.br +0 -0
- package/payload/server/public/assets/calendar-CLfLvsnV.js.gz +0 -0
- package/payload/server/public/assets/chat-DYq4FxMG.js +0 -1
- package/payload/server/public/assets/chat-DYq4FxMG.js.br +0 -0
- package/payload/server/public/assets/chat-DYq4FxMG.js.gz +0 -0
- package/payload/server/public/assets/chevron-left-DWt5cdUo.js +0 -1
- package/payload/server/public/assets/chevron-right-B9suVrYy.js +0 -1
- package/payload/server/public/assets/chevron-right-B9suVrYy.js.br +0 -0
- package/payload/server/public/assets/clock-DW6qVXtv.js +0 -1
- package/payload/server/public/assets/clock-DW6qVXtv.js.br +0 -0
- package/payload/server/public/assets/clock-DW6qVXtv.js.gz +0 -0
- package/payload/server/public/assets/data-C8rbOgds.js +0 -1
- package/payload/server/public/assets/data-C8rbOgds.js.br +0 -0
- package/payload/server/public/assets/data-C8rbOgds.js.gz +0 -0
- package/payload/server/public/assets/file-text-DNm0AzUz.js.br +0 -0
- package/payload/server/public/assets/file-text-DNm0AzUz.js.gz +0 -0
- package/payload/server/public/assets/graph-fAvmlVzh.js.br +0 -0
- package/payload/server/public/assets/graph-fAvmlVzh.js.gz +0 -0
- package/payload/server/public/assets/graph-labels-CwdkCxfs.js.br +0 -0
- package/payload/server/public/assets/graph-labels-CwdkCxfs.js.gz +0 -0
- package/payload/server/public/assets/maximize-2-B6tAPhC2.js.br +0 -0
- package/payload/server/public/assets/maximize-2-B6tAPhC2.js.gz +0 -0
- package/payload/server/public/assets/operator-CTKvBVjk.js.br +0 -0
- package/payload/server/public/assets/operator-CTKvBVjk.js.gz +0 -0
- package/payload/server/public/assets/page-BWoNW3sa.js +0 -32
- package/payload/server/public/assets/page-BWoNW3sa.js.br +0 -0
- package/payload/server/public/assets/page-BWoNW3sa.js.gz +0 -0
- package/payload/server/public/assets/page-O5HkHQw4.js.br +0 -0
- package/payload/server/public/assets/page-O5HkHQw4.js.gz +0 -0
- package/payload/server/public/assets/public-BF1rnVPa.js.br +0 -0
- package/payload/server/public/assets/public-BF1rnVPa.js.gz +0 -0
- package/payload/server/public/assets/rotate-ccw-B8TqfCy4.js.br +0 -0
- package/payload/server/public/assets/rotate-ccw-B8TqfCy4.js.gz +0 -0
- package/payload/server/public/assets/routines-4R7BLZyH.js +0 -1
- package/payload/server/public/assets/routines-4R7BLZyH.js.br +0 -0
- package/payload/server/public/assets/routines-4R7BLZyH.js.gz +0 -0
- package/payload/server/public/assets/skills-CQ9Qs2tg.js +0 -1
- package/payload/server/public/assets/skills-CQ9Qs2tg.js.br +0 -0
- package/payload/server/public/assets/skills-CQ9Qs2tg.js.gz +0 -0
- package/payload/server/public/assets/tasks-DePyOrRk.js.br +0 -0
- package/payload/server/public/assets/tasks-DePyOrRk.js.gz +0 -0
- package/payload/server/public/assets/time-entry-format-D9gtnG01.js.br +0 -0
- package/payload/server/public/assets/time-entry-format-D9gtnG01.js.gz +0 -0
- package/payload/server/public/assets/triangle-alert-DAtm2mLL.js.br +0 -0
- package/payload/server/public/assets/triangle-alert-DAtm2mLL.js.gz +0 -0
- package/payload/server/public/assets/useCopyFeedback-uMJT-p6i.js.br +0 -0
- package/payload/server/public/assets/useCopyFeedback-uMJT-p6i.js.gz +0 -0
- package/payload/server/public/assets/useSubAccountSwitcher-BufCPDDH.js +0 -14
- package/payload/server/public/assets/useSubAccountSwitcher-BufCPDDH.js.br +0 -0
- package/payload/server/public/assets/useSubAccountSwitcher-BufCPDDH.js.gz +0 -0
- package/payload/server/public/assets/useSubAccountSwitcher-iCzsV96x.css +0 -1
- package/payload/server/public/assets/useSubAccountSwitcher-iCzsV96x.css.br +0 -0
- package/payload/server/public/assets/useSubAccountSwitcher-iCzsV96x.css.gz +0 -0
- package/payload/server/public/assets/useVoiceRecorder-BFrPC-s7.js.br +0 -0
- package/payload/server/public/assets/useVoiceRecorder-BFrPC-s7.js.gz +0 -0
- package/payload/server/public/assets/wrench-CYgaN7K9.js.br +0 -1
- package/payload/server/public/assets/wrench-CYgaN7K9.js.gz +0 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
// @ts-expect-error - .mjs helper with JSDoc types, imported for its pure core
|
|
5
|
+
import { assembleTree, parseEnvFile } from '../../bin/portal-assemble.mjs'
|
|
6
|
+
|
|
7
|
+
// The real template, not a hand-written copy: a test that invented its own
|
|
8
|
+
// placeholder set would keep passing after the template gained one.
|
|
9
|
+
const TEMPLATE = readFileSync(
|
|
10
|
+
join(__dirname, '../../skills/data-portal/template/wrangler.toml'),
|
|
11
|
+
'utf8',
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
const shared = {
|
|
15
|
+
DATA_PORTAL_DB_NAME: 'house-data-portal',
|
|
16
|
+
DATA_PORTAL_DB_ID: 'db-123',
|
|
17
|
+
DATA_PORTAL_BUCKET: 'house-data-portal',
|
|
18
|
+
}
|
|
19
|
+
const cfg = {
|
|
20
|
+
accountId: 'acct-a',
|
|
21
|
+
projectName: 'ecolec-data-portal',
|
|
22
|
+
portalDomain: 'ecolec-data.example',
|
|
23
|
+
storageModel: 'shared',
|
|
24
|
+
}
|
|
25
|
+
const installOrigin = 'https://pub.example'
|
|
26
|
+
|
|
27
|
+
describe('portal assemble (Task 2099)', () => {
|
|
28
|
+
it('fills every placeholder from the shared identity and the account', () => {
|
|
29
|
+
const out = assembleTree({ toml: TEMPLATE, shared, cfg, installOrigin })
|
|
30
|
+
expect(out).toContain('database_name = "house-data-portal"')
|
|
31
|
+
expect(out).toContain('database_id = "db-123"')
|
|
32
|
+
expect(out).toContain('bucket_name = "house-data-portal"')
|
|
33
|
+
expect(out).toContain('PORTAL_ACCOUNT_ID = "acct-a"')
|
|
34
|
+
expect(out).toContain('name = "ecolec-data-portal"')
|
|
35
|
+
expect(out).toContain('PORTAL_INSTALL_ORIGIN = "https://pub.example"')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('leaves no placeholder behind, which is the whole post-condition', () => {
|
|
39
|
+
const out = assembleTree({ toml: TEMPLATE, shared, cfg, installOrigin })
|
|
40
|
+
expect(out).not.toMatch(/__[A-Z0-9_]+__/)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('gives two portals the same store and different account vars', () => {
|
|
44
|
+
// The shape of the shared model: one store, many portals, and the account
|
|
45
|
+
// var is the only thing that differs between two assembled trees.
|
|
46
|
+
const a = assembleTree({ toml: TEMPLATE, shared, cfg, installOrigin })
|
|
47
|
+
const b = assembleTree({
|
|
48
|
+
toml: TEMPLATE,
|
|
49
|
+
shared,
|
|
50
|
+
cfg: { ...cfg, accountId: 'acct-b', projectName: 'other-data-portal' },
|
|
51
|
+
installOrigin,
|
|
52
|
+
})
|
|
53
|
+
expect(a).toContain('database_id = "db-123"')
|
|
54
|
+
expect(b).toContain('database_id = "db-123"')
|
|
55
|
+
expect(a).toContain('PORTAL_ACCOUNT_ID = "acct-a"')
|
|
56
|
+
expect(b).toContain('PORTAL_ACCOUNT_ID = "acct-b"')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
for (const key of ['DATA_PORTAL_DB_NAME', 'DATA_PORTAL_DB_ID', 'DATA_PORTAL_BUCKET']) {
|
|
60
|
+
it(`refuses when ${key} is missing rather than emitting a blank binding`, () => {
|
|
61
|
+
expect(() =>
|
|
62
|
+
assembleTree({ toml: TEMPLATE, shared: { ...shared, [key]: '' }, cfg, installOrigin }),
|
|
63
|
+
).toThrow(new RegExp(key))
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
it('refuses a grandfathered account, which this template cannot serve', () => {
|
|
68
|
+
// Its database has no accountId column and every query here binds one, so
|
|
69
|
+
// assembling would deploy a portal that errors on the first request.
|
|
70
|
+
expect(() =>
|
|
71
|
+
assembleTree({ toml: TEMPLATE, shared, cfg: { ...cfg, storageModel: 'dedicated' }, installOrigin }),
|
|
72
|
+
).toThrow(/dedicated/)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('treats an absent storageModel as dedicated, never as shared', () => {
|
|
76
|
+
const { storageModel, ...noModel } = cfg
|
|
77
|
+
expect(() => assembleTree({ toml: TEMPLATE, shared, cfg: noModel, installOrigin })).toThrow(
|
|
78
|
+
/dedicated/,
|
|
79
|
+
)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('refuses an unrecognised storage model rather than guessing', () => {
|
|
83
|
+
expect(() =>
|
|
84
|
+
assembleTree({ toml: TEMPLATE, shared, cfg: { ...cfg, storageModel: 'Shared' }, installOrigin }),
|
|
85
|
+
).toThrow(/Shared/)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('refuses a config missing its account or project', () => {
|
|
89
|
+
expect(() =>
|
|
90
|
+
assembleTree({ toml: TEMPLATE, shared, cfg: { ...cfg, accountId: '' }, installOrigin }),
|
|
91
|
+
).toThrow(/accountId/)
|
|
92
|
+
expect(() =>
|
|
93
|
+
assembleTree({ toml: TEMPLATE, shared, cfg: { ...cfg, projectName: '' }, installOrigin }),
|
|
94
|
+
).toThrow(/projectName/)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('refuses an empty install origin', () => {
|
|
98
|
+
expect(() => assembleTree({ toml: TEMPLATE, shared, cfg, installOrigin: '' })).toThrow(/origin/)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('refuses when the config account disagrees with the account directory', () => {
|
|
102
|
+
// PORTAL_ACCOUNT_ID is baked in permanently and becomes the worker's tenant
|
|
103
|
+
// scope, but the house-side loops scope by the DIRECTORY name. If the two
|
|
104
|
+
// differ the worker writes under one tenant while the pull, index push and
|
|
105
|
+
// audit read under another, and nothing reports it: the reads just return
|
|
106
|
+
// nothing and the client's uploads never arrive.
|
|
107
|
+
expect(() =>
|
|
108
|
+
assembleTree({ toml: TEMPLATE, shared, cfg, installOrigin, expectedAccountId: 'acct-b' }),
|
|
109
|
+
).toThrow(/acct-a.*acct-b|acct-b.*acct-a/s)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('accepts when they agree', () => {
|
|
113
|
+
const out = assembleTree({ toml: TEMPLATE, shared, cfg, installOrigin, expectedAccountId: 'acct-a' })
|
|
114
|
+
expect(out).toContain('PORTAL_ACCOUNT_ID = "acct-a"')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('refuses an accountId the deployed key gate would reject', () => {
|
|
118
|
+
// authorizeKey enforces [a-z0-9-] on both key segments, so baking anything
|
|
119
|
+
// else means every upload 403s after deploy instead of failing here.
|
|
120
|
+
for (const bad of ['Acct-A', 'acct_a', 'acct a', 'acct/a']) {
|
|
121
|
+
expect(() =>
|
|
122
|
+
assembleTree({ toml: TEMPLATE, shared, cfg: { ...cfg, accountId: bad }, installOrigin }),
|
|
123
|
+
).toThrow(/\[a-z0-9-\]/)
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('parseEnvFile', () => {
|
|
129
|
+
it('reads KEY=value, trims the value, and ignores comments and blanks', () => {
|
|
130
|
+
const env = parseEnvFile('# c\n\nA=1\n B=2 \nnot a pair\n')
|
|
131
|
+
expect(env.A).toBe('1')
|
|
132
|
+
expect(env.B).toBe('2')
|
|
133
|
+
expect(env['not a pair']).toBeUndefined()
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('does not accept a space before the equals, matching readHouseCredential', () => {
|
|
137
|
+
// Deliberately the same regex as lib/storage-broker's house-credential
|
|
138
|
+
// reader, because both parse the SAME file. A looser parser here would
|
|
139
|
+
// accept a line the broker silently ignores, so the two would disagree
|
|
140
|
+
// about what the house config says.
|
|
141
|
+
expect(parseEnvFile('B = 2\n').B).toBeUndefined()
|
|
142
|
+
})
|
|
143
|
+
})
|
|
@@ -10,6 +10,9 @@ function harness(row: { devicePath: string } | null) {
|
|
|
10
10
|
const lines: string[] = []
|
|
11
11
|
|
|
12
12
|
const env = {
|
|
13
|
+
// The portal's own account, baked in at assemble time. It scopes every
|
|
14
|
+
// manifest statement and is the first segment of every key below.
|
|
15
|
+
PORTAL_ACCOUNT_ID: 'acct',
|
|
13
16
|
BUCKET: {
|
|
14
17
|
async delete() {
|
|
15
18
|
state.objectDeleted = true
|
|
@@ -25,6 +28,8 @@ function harness(row: { devicePath: string } | null) {
|
|
|
25
28
|
return {
|
|
26
29
|
async first() {
|
|
27
30
|
if (sql.includes('FROM sessions')) {
|
|
31
|
+
// The people row's account has to match the portal's, or
|
|
32
|
+
// resolveSession returns null and every case below 401s.
|
|
28
33
|
return { ownerId: 'alice', accountId: 'acct', folders: '' }
|
|
29
34
|
}
|
|
30
35
|
if (sql.includes('SELECT devicePath')) return row
|
|
@@ -51,7 +56,7 @@ function harness(row: { devicePath: string } | null) {
|
|
|
51
56
|
describe('delete branches on whether the file has moved', () => {
|
|
52
57
|
it('deletes object and row outright when the file is still in transit', async () => {
|
|
53
58
|
const h = harness({ devicePath: '' })
|
|
54
|
-
const res = await processDelete('s', 'alice/a.pdf', h.env, h.log, () => 1)
|
|
59
|
+
const res = await processDelete('s', 'acct/alice/a.pdf', h.env, h.log, () => 1)
|
|
55
60
|
expect(res.status).toBe(200)
|
|
56
61
|
expect(h.state.objectDeleted).toBe(true)
|
|
57
62
|
expect(h.state.rowDeleted).toBe(true)
|
|
@@ -63,7 +68,7 @@ describe('delete branches on whether the file has moved', () => {
|
|
|
63
68
|
// here would report success while the client's file stayed on the device
|
|
64
69
|
// forever — the exact failure this branch exists to prevent.
|
|
65
70
|
const h = harness({ devicePath: 'jobs/a.pdf' })
|
|
66
|
-
const res = await processDelete('s', 'alice/jobs/a.pdf', h.env, h.log, () => 1)
|
|
71
|
+
const res = await processDelete('s', 'acct/alice/jobs/a.pdf', h.env, h.log, () => 1)
|
|
67
72
|
expect(res.status).toBe(200)
|
|
68
73
|
expect(h.state.marked).toBe(true)
|
|
69
74
|
expect(h.state.rowDeleted).toBe(false)
|
|
@@ -73,13 +78,13 @@ describe('delete branches on whether the file has moved', () => {
|
|
|
73
78
|
|
|
74
79
|
it('tells the client the withdrawal is pending, not complete', async () => {
|
|
75
80
|
const h = harness({ devicePath: 'jobs/a.pdf' })
|
|
76
|
-
const res = await processDelete('s', 'alice/jobs/a.pdf', h.env, h.log, () => 1)
|
|
81
|
+
const res = await processDelete('s', 'acct/alice/jobs/a.pdf', h.env, h.log, () => 1)
|
|
77
82
|
expect(res.payload).toMatchObject({ ok: true, pending: true })
|
|
78
83
|
})
|
|
79
84
|
|
|
80
85
|
it('reports an unknown key as not found without writing anything', async () => {
|
|
81
86
|
const h = harness(null)
|
|
82
|
-
const res = await processDelete('s', 'alice/nope.pdf', h.env, h.log, () => 1)
|
|
87
|
+
const res = await processDelete('s', 'acct/alice/nope.pdf', h.env, h.log, () => 1)
|
|
83
88
|
expect(res.status).toBe(404)
|
|
84
89
|
expect(h.state.objectDeleted).toBe(false)
|
|
85
90
|
expect(h.state.marked).toBe(false)
|
|
@@ -87,8 +92,10 @@ describe('delete branches on whether the file has moved', () => {
|
|
|
87
92
|
})
|
|
88
93
|
|
|
89
94
|
it('still refuses another person key before reading anything', async () => {
|
|
95
|
+
// Same account, different owner segment: the owner half of the check is
|
|
96
|
+
// what has to reject this one.
|
|
90
97
|
const h = harness({ devicePath: '' })
|
|
91
|
-
const res = await processDelete('s', 'bob/a.pdf', h.env, h.log, () => 1)
|
|
98
|
+
const res = await processDelete('s', 'acct/bob/a.pdf', h.env, h.log, () => 1)
|
|
92
99
|
expect(res.status).toBe(403)
|
|
93
100
|
expect(h.state.objectDeleted).toBe(false)
|
|
94
101
|
})
|
|
@@ -21,6 +21,10 @@ function makeEnv(
|
|
|
21
21
|
onQuery: (query: string) => void = () => {},
|
|
22
22
|
) {
|
|
23
23
|
return {
|
|
24
|
+
// The tenant scope, baked into wrangler.toml at assemble time (Task 2099).
|
|
25
|
+
// One shared store backs every portal, so this is what the directory read
|
|
26
|
+
// binds, and what the session's own account has to agree with.
|
|
27
|
+
PORTAL_ACCOUNT_ID: 'acc-a',
|
|
24
28
|
DB: {
|
|
25
29
|
prepare(query: string) {
|
|
26
30
|
onQuery(query)
|
|
@@ -34,12 +38,17 @@ function makeEnv(
|
|
|
34
38
|
return { meta: { changes: 0 } }
|
|
35
39
|
},
|
|
36
40
|
async all<T>() {
|
|
41
|
+
// The directory read binds the portal's own account id, so `bound[0]`
|
|
42
|
+
// is PORTAL_ACCOUNT_ID rather than anything off the session.
|
|
37
43
|
if (/FROM directory/i.test(query)) {
|
|
38
44
|
return { results: DIRECTORY.filter((d) => d.accountId === bound[0]) as T[] }
|
|
39
45
|
}
|
|
40
46
|
return { results: [] as T[] }
|
|
41
47
|
},
|
|
42
48
|
async first<T>() {
|
|
49
|
+
// resolveSession binds (sessionId, accountId, nowMs) and reads the
|
|
50
|
+
// sessions/people join, so the row's `accountId` is the person's.
|
|
51
|
+
// It has to equal the portal's or the session does not resolve.
|
|
43
52
|
if (/FROM sessions/i.test(query)) return (sessions[bound[0] as string] ?? null) as T | null
|
|
44
53
|
return null as T | null
|
|
45
54
|
},
|
|
@@ -149,11 +158,14 @@ describe('processFiles directory listing', () => {
|
|
|
149
158
|
expect(JSON.stringify(r.payload)).not.toContain('secret.pdf')
|
|
150
159
|
})
|
|
151
160
|
|
|
152
|
-
|
|
161
|
+
// A person with no account bound used to resolve and simply see no folders.
|
|
162
|
+
// On the shared store an absent tenant is a contradiction, not a narrower
|
|
163
|
+
// scope, so resolveSession fails closed and nothing is listed at all.
|
|
164
|
+
it('denies when the person has no account bound', async () => {
|
|
153
165
|
const env = makeEnv({ 'sess-a': { ownerId: 'alice', accountId: '' } })
|
|
154
166
|
const r = await processFiles('sess-a', env, () => {}, 0, '')
|
|
155
|
-
expect(r.status).toBe(
|
|
156
|
-
expect(r.payload
|
|
167
|
+
expect(r.status).toBe(401)
|
|
168
|
+
expect(r.payload).toEqual({ ok: false, error: 'denied' })
|
|
157
169
|
})
|
|
158
170
|
|
|
159
171
|
it('still denies without a session', async () => {
|
|
@@ -12,10 +12,11 @@ import {
|
|
|
12
12
|
const run = promisify(execFile)
|
|
13
13
|
const SCRIPT = resolve(dirname(fileURLToPath(import.meta.url)), '../../bin/portal-enrol.mjs')
|
|
14
14
|
|
|
15
|
-
/** `--account` is required (it
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
/** `--account` is required (it names the portal's own account, which is half the
|
|
16
|
+
* person's key on the shared store and decides whose folders they read), but it
|
|
17
|
+
* is irrelevant to the passcode behaviour most of these tests cover, so the
|
|
18
|
+
* helper supplies one unless the caller names its own. Tests that assert the
|
|
19
|
+
* requirement itself call `run` directly. */
|
|
19
20
|
const enrol = (args: string[]) =>
|
|
20
21
|
run('node', [SCRIPT, ...(args.includes('--account') ? args : [...args, '--account', 'acc-1'])])
|
|
21
22
|
|
|
@@ -173,6 +174,72 @@ describe('portal-enrol', () => {
|
|
|
173
174
|
await expect(enrol(['--owner', 'alice', '--name', 'Bob\nDROP'])).rejects.toThrow()
|
|
174
175
|
})
|
|
175
176
|
|
|
177
|
+
describe('the conflict target follows the storage model (Task 2099)', () => {
|
|
178
|
+
/** A pre-2099 portal's own database: `people` keyed on ownerId alone. */
|
|
179
|
+
async function legacyDb(dir: string) {
|
|
180
|
+
const { mkdtemp } = await import('node:fs/promises')
|
|
181
|
+
const { tmpdir } = await import('node:os')
|
|
182
|
+
const root = await mkdtemp(resolve(tmpdir(), dir))
|
|
183
|
+
const db = resolve(root, 'portal.db')
|
|
184
|
+
await run('sqlite3', [
|
|
185
|
+
db,
|
|
186
|
+
'CREATE TABLE people (ownerId TEXT PRIMARY KEY, name TEXT NOT NULL, salt TEXT NOT NULL, ' +
|
|
187
|
+
"hash TEXT NOT NULL, createdAt TEXT NOT NULL, accountId TEXT NOT NULL DEFAULT '', " +
|
|
188
|
+
"folders TEXT NOT NULL DEFAULT '');",
|
|
189
|
+
])
|
|
190
|
+
return {
|
|
191
|
+
exec: (sql: string) => run('sqlite3', [db, sql]),
|
|
192
|
+
query: async (sql: string) => (await run('sqlite3', [db, sql])).stdout.trim(),
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
it('lets two tenants each enrol the same person on the shared store', async () => {
|
|
197
|
+
const db = await d1('enrol-two-tenants-')
|
|
198
|
+
await db.exec(parse(await enrol(['--owner', 'rob', '--name', 'Rob', '--account', 'acct-a'])).sql!)
|
|
199
|
+
await db.exec(parse(await enrol(['--owner', 'rob', '--name', 'Rob', '--account', 'acct-b'])).sql!)
|
|
200
|
+
expect(await db.query('SELECT COUNT(*) FROM people;')).toBe('2')
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('still rotates on a grandfathered database, which the composite target cannot', async () => {
|
|
204
|
+
// The regression this test exists for. A pre-2099 people table has no
|
|
205
|
+
// unique index on (accountId, ownerId), and SQLite does not degrade: it
|
|
206
|
+
// refuses the whole statement with "ON CONFLICT clause does not match any
|
|
207
|
+
// PRIMARY KEY or UNIQUE constraint". That would take passcode rotation
|
|
208
|
+
// away from every existing portal, and rotation is the one remedy for a
|
|
209
|
+
// leaked passcode.
|
|
210
|
+
const db = await legacyDb('enrol-legacy-')
|
|
211
|
+
const first = await enrol(['--owner', 'dale', '--name', 'Dale', '--account', 'acct-a', '--storage-model', 'dedicated'])
|
|
212
|
+
await db.exec(parse(first).sql!)
|
|
213
|
+
const second = await enrol(['--owner', 'dale', '--name', 'Dale', '--account', 'acct-a', '--storage-model', 'dedicated'])
|
|
214
|
+
await db.exec(parse(second).sql!)
|
|
215
|
+
|
|
216
|
+
expect(await db.query('SELECT COUNT(*) FROM people;')).toBe('1')
|
|
217
|
+
const [salt, hash] = (await db.query("SELECT salt, hash FROM people WHERE ownerId='dale';")).split('|')
|
|
218
|
+
expect(await verifyPasscode(parse(second).passcode!, salt, hash)).toBe(true)
|
|
219
|
+
expect(await verifyPasscode(parse(first).passcode!, salt, hash)).toBe(false)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('names the composite target only on the shared model', async () => {
|
|
223
|
+
const shared = parse(await enrol(['--owner', 'a', '--name', 'A', '--account', 'acct-a'])).sql!
|
|
224
|
+
const dedicated = parse(
|
|
225
|
+
await enrol(['--owner', 'a', '--name', 'A', '--account', 'acct-a', '--storage-model', 'dedicated']),
|
|
226
|
+
).sql!
|
|
227
|
+
expect(shared).toContain('ON CONFLICT(accountId, ownerId)')
|
|
228
|
+
expect(dedicated).toContain('ON CONFLICT(ownerId)')
|
|
229
|
+
// The dedicated path keeps moving a person between sub-accounts, exactly
|
|
230
|
+
// as it did before 2099. The shared path must not: accountId is half the
|
|
231
|
+
// key there, so it cannot also be a field the match rewrites.
|
|
232
|
+
expect(dedicated).toContain('accountId=excluded.accountId')
|
|
233
|
+
expect(shared).not.toContain('accountId=excluded.accountId')
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('refuses an unrecognised storage model', async () => {
|
|
237
|
+
await expect(
|
|
238
|
+
enrol(['--owner', 'a', '--name', 'A', '--account', 'acct-a', '--storage-model', 'Shared']),
|
|
239
|
+
).rejects.toThrow()
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
|
|
176
243
|
describe('against a real SQLite database built from schema.sql', () => {
|
|
177
244
|
it('applies cleanly', async () => {
|
|
178
245
|
const db = await d1('enrol-apply-')
|
|
@@ -183,9 +250,10 @@ describe('portal-enrol', () => {
|
|
|
183
250
|
|
|
184
251
|
it('rotates a passcode by re-running, which is the ONLY sanctioned rotation path', async () => {
|
|
185
252
|
// SKILL.md: "Rotating a passcode is a re-run of the script for that person."
|
|
186
|
-
// A bare INSERT cannot do that
|
|
187
|
-
// run
|
|
188
|
-
//
|
|
253
|
+
// A bare INSERT cannot do that: (accountId, ownerId) is the primary key, so
|
|
254
|
+
// the second run for the same person at the same portal dies on a UNIQUE
|
|
255
|
+
// violation in a SEPARATE process, AFTER the script has already printed a
|
|
256
|
+
// new passcode and exited 0. The operator hands out a
|
|
189
257
|
// passcode that never worked while the leaked one still does, and pcCheck
|
|
190
258
|
// revocation never fires. Rotation is exactly the case where a remedy that
|
|
191
259
|
// silently no-ops is worse than no remedy.
|
|
@@ -223,13 +291,39 @@ describe('portal-enrol', () => {
|
|
|
223
291
|
await db.exec(parse(await enrol(['--owner', 'alice', '--name', 'Alice', '--account', 'acc-a'])).sql!)
|
|
224
292
|
expect(await db.query("SELECT accountId FROM people WHERE ownerId='alice';")).toBe('acc-a')
|
|
225
293
|
|
|
226
|
-
// Re-enrolling
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
|
|
294
|
+
// Re-enrolling the same person at the same portal is the rotation path, and
|
|
295
|
+
// the binding survives it: accountId is half the conflict key, so the DO
|
|
296
|
+
// UPDATE no longer names it and cannot blank it back to the column default.
|
|
297
|
+
await db.exec(parse(await enrol(['--owner', 'alice', '--name', 'Alice', '--account', 'acc-a'])).sql!)
|
|
298
|
+
expect(await db.query("SELECT accountId FROM people WHERE ownerId='alice';")).toBe('acc-a')
|
|
230
299
|
expect(await db.query('SELECT COUNT(*) FROM people;')).toBe('1')
|
|
231
300
|
})
|
|
232
301
|
|
|
302
|
+
it('enrols the same ownerId at a second portal as a separate person', async () => {
|
|
303
|
+
// The shared store holds every portal's people, so two clients may each
|
|
304
|
+
// enrol someone called `alice` (Task 2099). Keyed on ownerId alone the
|
|
305
|
+
// second enrolment would read as a rotation of the first client's person
|
|
306
|
+
// and overwrite their passcode, their grant, and the ownership of their
|
|
307
|
+
// files; keyed on (accountId, ownerId) it is a different person entirely.
|
|
308
|
+
// `--account` names the portal, so re-running with a different one no
|
|
309
|
+
// longer MOVES a person, and neither passcode is disturbed by the other.
|
|
310
|
+
const db = await d1('enrol-account-split-')
|
|
311
|
+
const a = await enrol(['--owner', 'alice', '--name', 'Alice', '--account', 'acc-a'])
|
|
312
|
+
await db.exec(parse(a).sql!)
|
|
313
|
+
const b = await enrol(['--owner', 'alice', '--name', 'Alice', '--account', 'acc-b'])
|
|
314
|
+
await db.exec(parse(b).sql!)
|
|
315
|
+
|
|
316
|
+
expect(await db.query("SELECT accountId FROM people WHERE ownerId='alice' ORDER BY accountId;"))
|
|
317
|
+
.toBe('acc-a\nacc-b')
|
|
318
|
+
expect(await db.query('SELECT COUNT(*) FROM people;')).toBe('2')
|
|
319
|
+
|
|
320
|
+
const [saltA, hashA] = (
|
|
321
|
+
await db.query("SELECT salt, hash FROM people WHERE ownerId='alice' AND accountId='acc-a';")
|
|
322
|
+
).split('|')
|
|
323
|
+
expect(await verifyPasscode(parse(a).passcode!, saltA, hashA)).toBe(true)
|
|
324
|
+
expect(await verifyPasscode(parse(b).passcode!, saltA, hashA)).toBe(false)
|
|
325
|
+
})
|
|
326
|
+
|
|
233
327
|
it('declares a directory table keyed uniquely on account and path', async () => {
|
|
234
328
|
const db = await d1('enrol-directory-')
|
|
235
329
|
await db.exec(
|
|
@@ -35,11 +35,15 @@ function makeEnv(
|
|
|
35
35
|
},
|
|
36
36
|
async first<T>() {
|
|
37
37
|
if (/FROM sessions/i.test(query)) {
|
|
38
|
+
// The people row carries this portal's own account, which is what
|
|
39
|
+
// resolveSession requires: on the shared store a session resolves
|
|
40
|
+
// only against the account baked into PORTAL_ACCOUNT_ID.
|
|
38
41
|
return (bound[0] === 'sess-a'
|
|
39
42
|
? { ownerId: 'alice', accountId, folders }
|
|
40
43
|
: null) as T | null
|
|
41
44
|
}
|
|
42
45
|
if (/FROM directory/i.test(query)) {
|
|
46
|
+
// Bound (accountId, relPath), so the path is the second bind.
|
|
43
47
|
const rel = bound[1] as string
|
|
44
48
|
if (dirPaths.includes(rel)) return { relPath: rel, isDir: 1 } as T
|
|
45
49
|
return (paths.includes(rel) ? { relPath: rel, isDir: 0 } : null) as T | null
|
|
@@ -50,6 +54,9 @@ function makeEnv(
|
|
|
50
54
|
return stmt
|
|
51
55
|
},
|
|
52
56
|
},
|
|
57
|
+
// This portal's own tenant scope, baked into wrangler.toml at assemble time.
|
|
58
|
+
// Every query and every object key binds it; the store itself is shared.
|
|
59
|
+
PORTAL_ACCOUNT_ID: accountId,
|
|
53
60
|
PORTAL_FETCH_SECRET: 'secret' in opts ? opts.secret : 'a-secret',
|
|
54
61
|
PORTAL_INSTALL_ORIGIN: 'origin' in opts ? opts.origin : 'https://install.example',
|
|
55
62
|
} as unknown as PortalEnv
|
|
@@ -154,7 +161,12 @@ describe('processIndexedDownload', () => {
|
|
|
154
161
|
expect(r.status).toBe(401)
|
|
155
162
|
})
|
|
156
163
|
|
|
157
|
-
|
|
164
|
+
// The account is the portal's own PORTAL_ACCOUNT_ID now, not a property of the
|
|
165
|
+
// person, so "no account bound" means an unconfigured portal. That is caught a
|
|
166
|
+
// step earlier than it used to be: a session resolves only against this
|
|
167
|
+
// portal's account and the empty string is never a match, so the refusal reads
|
|
168
|
+
// as denied. Either way no link is minted against an unscoped store.
|
|
169
|
+
it('refuses when the portal has no account bound', async () => {
|
|
158
170
|
const r = await processIndexedDownload(
|
|
159
171
|
'sess-a',
|
|
160
172
|
PATH,
|
|
@@ -163,7 +175,8 @@ describe('processIndexedDownload', () => {
|
|
|
163
175
|
1000,
|
|
164
176
|
okHead as never,
|
|
165
177
|
)
|
|
166
|
-
expect(r.status).toBe(
|
|
178
|
+
expect(r.status).toBe(401)
|
|
179
|
+
expect(r.url).toBeUndefined()
|
|
167
180
|
})
|
|
168
181
|
|
|
169
182
|
it('never mints a link when the secret or origin is unconfigured', async () => {
|
|
@@ -32,6 +32,10 @@ function harness(row: { devicePath: string } | null, opts: { configured?: boolea
|
|
|
32
32
|
}
|
|
33
33
|
},
|
|
34
34
|
},
|
|
35
|
+
// Baked in at assemble time, and the tenant scope for the session lookup,
|
|
36
|
+
// the key check and the manifest read. The store is shared, so this is what
|
|
37
|
+
// separates this portal's rows and objects from another client's.
|
|
38
|
+
PORTAL_ACCOUNT_ID: ACC,
|
|
35
39
|
PORTAL_FETCH_SECRET: configured ? 'test-secret-value' : undefined,
|
|
36
40
|
PORTAL_INSTALL_ORIGIN: configured ? 'https://install.example' : undefined,
|
|
37
41
|
} as unknown as PortalEnv
|
|
@@ -41,7 +45,7 @@ function harness(row: { devicePath: string } | null, opts: { configured?: boolea
|
|
|
41
45
|
describe('downloading an upload that has moved to the device', () => {
|
|
42
46
|
it('streams R2 bytes while the row is still in transit', async () => {
|
|
43
47
|
const h = harness({ devicePath: '' })
|
|
44
|
-
const res = await processDownload('s',
|
|
48
|
+
const res = await processDownload('s', `${ACC}/alice/a.pdf`, h.env, h.log, 1000)
|
|
45
49
|
expect(res.status).toBe(200)
|
|
46
50
|
expect(res.body).not.toBeNull()
|
|
47
51
|
expect(res.url).toBeUndefined()
|
|
@@ -51,7 +55,7 @@ describe('downloading an upload that has moved to the device', () => {
|
|
|
51
55
|
// The object is gone from R2. Without this branch the client's own upload
|
|
52
56
|
// would 404 the moment the device collected it.
|
|
53
57
|
const h = harness({ devicePath: 'uploads/alice/a.pdf' })
|
|
54
|
-
const res = await processDownload('s',
|
|
58
|
+
const res = await processDownload('s', `${ACC}/alice/a.pdf`, h.env, h.log, 1000)
|
|
55
59
|
expect(res.status).toBe(200)
|
|
56
60
|
expect(res.url).toContain('/api/portal/fetch')
|
|
57
61
|
expect(new URL(res.url!).searchParams.get('owner')).toBe('alice')
|
|
@@ -61,7 +65,7 @@ describe('downloading an upload that has moved to the device', () => {
|
|
|
61
65
|
|
|
62
66
|
it('refuses another person key before anything is minted', async () => {
|
|
63
67
|
const h = harness({ devicePath: 'uploads/bob/a.pdf' })
|
|
64
|
-
const res = await processDownload('s',
|
|
68
|
+
const res = await processDownload('s', `${ACC}/bob/a.pdf`, h.env, h.log, 1000)
|
|
65
69
|
expect(res.status).toBe(403)
|
|
66
70
|
expect(res.url).toBeUndefined()
|
|
67
71
|
})
|
|
@@ -70,14 +74,14 @@ describe('downloading an upload that has moved to the device', () => {
|
|
|
70
74
|
// Same reasoning as the indexed path: from the client's side the file
|
|
71
75
|
// genuinely is unreachable, and the operator's signal is the log line.
|
|
72
76
|
const h = harness({ devicePath: 'uploads/alice/a.pdf' }, { configured: false })
|
|
73
|
-
const res = await processDownload('s',
|
|
77
|
+
const res = await processDownload('s', `${ACC}/alice/a.pdf`, h.env, h.log, 1000)
|
|
74
78
|
expect(res.status).toBe(503)
|
|
75
79
|
expect(h.lines.some((l) => l.includes('result=unconfigured'))).toBe(true)
|
|
76
80
|
})
|
|
77
81
|
|
|
78
82
|
it('reports absent for a key with no manifest row at all', async () => {
|
|
79
83
|
const h = harness(null)
|
|
80
|
-
const res = await processDownload('s',
|
|
84
|
+
const res = await processDownload('s', `${ACC}/alice/gone.pdf`, h.env, h.log, 1000)
|
|
81
85
|
expect(res.status).toBe(404)
|
|
82
86
|
})
|
|
83
87
|
})
|
|
@@ -10,6 +10,17 @@ const SQL = readFileSync(
|
|
|
10
10
|
'utf8',
|
|
11
11
|
)
|
|
12
12
|
|
|
13
|
+
// accountId appears on several tables, so asserting it against the whole file
|
|
14
|
+
// proves nothing about which table has it. No CREATE TABLE body in this schema
|
|
15
|
+
// contains a semicolon, so the statement terminator bounds the block.
|
|
16
|
+
const tableBody = (name: string): string => {
|
|
17
|
+
const found = SQL.match(
|
|
18
|
+
new RegExp(`CREATE TABLE IF NOT EXISTS ${name} \\(([^;]*)\\)`),
|
|
19
|
+
)
|
|
20
|
+
expect(found, `no CREATE TABLE for ${name}`).not.toBeNull()
|
|
21
|
+
return found![1]
|
|
22
|
+
}
|
|
23
|
+
|
|
13
24
|
describe('portal schema shape', () => {
|
|
14
25
|
it('carries the manifest lifecycle columns', () => {
|
|
15
26
|
expect(SQL).toMatch(/targetPath\s+TEXT\s+NOT NULL\s+DEFAULT\s+''/)
|
|
@@ -23,11 +34,42 @@ describe('portal schema shape', () => {
|
|
|
23
34
|
expect(SQL).toMatch(/currentGeneration\s+INTEGER\s+NOT NULL/)
|
|
24
35
|
})
|
|
25
36
|
|
|
26
|
-
it('
|
|
27
|
-
// objectKey is now ownerId/targetPath/filename
|
|
28
|
-
//
|
|
29
|
-
// the
|
|
30
|
-
|
|
37
|
+
it('scopes objectKey uniqueness by account, which the upsert conflict target now needs', () => {
|
|
38
|
+
// objectKey is now accountId/ownerId/targetPath/filename, and on the shared
|
|
39
|
+
// store it is unique only within an account. The upsert conflicts on
|
|
40
|
+
// (accountId, objectKey), so this pins that the constraint moved to the
|
|
41
|
+
// table level rather than being dropped while the key shape changed
|
|
42
|
+
// underneath it. The bare column-level UNIQUE must be gone: it is account
|
|
43
|
+
// blind, so one client uploading at another client's key would take over
|
|
44
|
+
// the existing row, reset its ingested flag, and redirect the file.
|
|
45
|
+
expect(SQL).toMatch(/objectKey\s+TEXT\s+NOT NULL/)
|
|
46
|
+
expect(SQL).not.toMatch(/objectKey\s+TEXT\s+UNIQUE/)
|
|
47
|
+
expect(SQL).toMatch(/UNIQUE\s*\(\s*accountId,\s*objectKey\s*\)/)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('carries the tenant discriminator on every table holding client data', () => {
|
|
51
|
+
// One shared house-owned database backs every portal (Task 2099), so
|
|
52
|
+
// accountId is the only thing separating one client's rows from another's.
|
|
53
|
+
// A missing discriminator here is not a loud "no such column" like the
|
|
54
|
+
// lifecycle columns above; it is a silent cross-tenant read.
|
|
55
|
+
expect(tableBody('sessions')).toMatch(/accountId\s+TEXT\s+NOT NULL/)
|
|
56
|
+
expect(tableBody('manifest')).toMatch(/accountId\s+TEXT\s+NOT NULL/)
|
|
57
|
+
// people is keyed on the pair, not on ownerId alone: two clients may each
|
|
58
|
+
// enrol a person called `rob`, and a bare ownerId key would either reject
|
|
59
|
+
// the second enrolment or let its upsert overwrite the first client's
|
|
60
|
+
// person, handing over their passcode and their folder grant.
|
|
61
|
+
expect(tableBody('people')).toMatch(
|
|
62
|
+
/PRIMARY KEY\s*\(\s*accountId,\s*ownerId\s*\)/,
|
|
63
|
+
)
|
|
64
|
+
expect(tableBody('people')).not.toMatch(/ownerId\s+TEXT\s+PRIMARY KEY/)
|
|
65
|
+
// The pull and the audit both filter by account, so it carries its own
|
|
66
|
+
// index rather than riding the owner one.
|
|
67
|
+
expect(SQL).toMatch(
|
|
68
|
+
/CREATE INDEX IF NOT EXISTS manifest_account ON manifest \(accountId\)/,
|
|
69
|
+
)
|
|
70
|
+
// auth_attempts is the deliberate exception: its tenant rides inside the
|
|
71
|
+
// scope string, which is now accountId|ownerId|clientIp.
|
|
72
|
+
expect(tableBody('auth_attempts')).not.toMatch(/accountId/)
|
|
31
73
|
})
|
|
32
74
|
|
|
33
75
|
it('scopes the directory uniqueness by generation', () => {
|
|
@@ -32,20 +32,44 @@ describe('resolveSession folders', () => {
|
|
|
32
32
|
const s = await resolveSession(
|
|
33
33
|
db({ ownerId: 'alice', accountId: 'acc-a', folders: 'quotes,output' }),
|
|
34
34
|
'sess',
|
|
35
|
+
'acc-a',
|
|
35
36
|
1,
|
|
36
37
|
)
|
|
37
38
|
expect(s?.folders).toEqual(['quotes', 'output'])
|
|
38
39
|
})
|
|
39
40
|
|
|
40
41
|
it('returns an empty array for an empty grant', async () => {
|
|
41
|
-
const s = await resolveSession(
|
|
42
|
+
const s = await resolveSession(
|
|
43
|
+
db({ ownerId: 'alice', accountId: 'acc-a', folders: '' }),
|
|
44
|
+
'sess',
|
|
45
|
+
'acc-a',
|
|
46
|
+
1,
|
|
47
|
+
)
|
|
42
48
|
expect(s?.folders).toEqual([])
|
|
43
49
|
})
|
|
44
50
|
|
|
45
51
|
it('returns an empty array when the column is absent', async () => {
|
|
46
|
-
const s = await resolveSession(db({ ownerId: 'alice', accountId: 'acc-a' }), 'sess', 1)
|
|
52
|
+
const s = await resolveSession(db({ ownerId: 'alice', accountId: 'acc-a' }), 'sess', 'acc-a', 1)
|
|
47
53
|
expect(s?.folders).toEqual([])
|
|
48
54
|
})
|
|
55
|
+
|
|
56
|
+
it('refuses a row whose account disagrees with the portal own account', async () => {
|
|
57
|
+
// The query already scopes by account, so a row coming back for a different
|
|
58
|
+
// tenant is a contradiction. On a shared store the safe reading of a
|
|
59
|
+
// contradiction is nothing, never the row.
|
|
60
|
+
const s = await resolveSession(
|
|
61
|
+
db({ ownerId: 'alice', accountId: 'acc-b', folders: 'quotes' }),
|
|
62
|
+
'sess',
|
|
63
|
+
'acc-a',
|
|
64
|
+
1,
|
|
65
|
+
)
|
|
66
|
+
expect(s).toBeNull()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('refuses a legacy row that carries no account at all', async () => {
|
|
70
|
+
const s = await resolveSession(db({ ownerId: 'alice', folders: 'quotes' }), 'sess', 'acc-a', 1)
|
|
71
|
+
expect(s).toBeNull()
|
|
72
|
+
})
|
|
49
73
|
})
|
|
50
74
|
|
|
51
75
|
describe('grantAllows', () => {
|
|
@@ -48,13 +48,21 @@ describe('isSafeTargetSegment', () => {
|
|
|
48
48
|
})
|
|
49
49
|
|
|
50
50
|
describe('uploadKey', () => {
|
|
51
|
-
it('collapses to owner/filename when untargeted', () => {
|
|
52
|
-
expect(uploadKey('alice', '', 'a.pdf')).toBe('alice/a.pdf')
|
|
51
|
+
it('collapses to account/owner/filename when untargeted', () => {
|
|
52
|
+
expect(uploadKey('acct-a', 'alice', '', 'a.pdf')).toBe('acct-a/alice/a.pdf')
|
|
53
53
|
})
|
|
54
54
|
|
|
55
55
|
it('carries the target so two folders never collide', () => {
|
|
56
|
-
expect(uploadKey('alice', 'jobs', 'a.pdf')).toBe('alice/jobs/a.pdf')
|
|
57
|
-
expect(uploadKey('alice', 'jobs/2026', 'a.pdf')).toBe('alice/jobs/2026/a.pdf')
|
|
56
|
+
expect(uploadKey('acct-a', 'alice', 'jobs', 'a.pdf')).toBe('acct-a/alice/jobs/a.pdf')
|
|
57
|
+
expect(uploadKey('acct-a', 'alice', 'jobs/2026', 'a.pdf')).toBe('acct-a/alice/jobs/2026/a.pdf')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('separates two tenants that share an owner id and a filename', () => {
|
|
61
|
+
// The store is shared and objectKey is unique per account, so this pair
|
|
62
|
+
// must differ or one client upload would take over the other client row.
|
|
63
|
+
expect(uploadKey('acct-a', 'alice', '', 'a.pdf')).not.toBe(
|
|
64
|
+
uploadKey('acct-b', 'alice', '', 'a.pdf'),
|
|
65
|
+
)
|
|
58
66
|
})
|
|
59
67
|
})
|
|
60
68
|
|