aimeat 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/dist/build-stamp.json +4 -4
  2. package/dist/locales/en.json +9103 -9101
  3. package/dist/locales/es.json +9095 -0
  4. package/dist/locales/fi.json +9103 -9101
  5. package/dist/public/cortex-bundled/aimeat-input.js +611 -0
  6. package/dist/public/cortex-bundled/aimeat-input.yaml +149 -0
  7. package/dist/public/css/views/agents-detail.css +11 -0
  8. package/dist/public/js/utils.js +12 -7
  9. package/dist/public/spa.html +5 -2
  10. package/dist/public/views/admin/email-tab.js +459 -458
  11. package/dist/public/views/profile/agents/agent-card.js +25 -0
  12. package/dist/public/views/profile/agents/scope-model.js +296 -288
  13. package/dist/public/views/profile/agents-tab.js +26 -3
  14. package/dist/scripts/check-ai-disclosure.d.ts.map +1 -1
  15. package/dist/scripts/check-ai-disclosure.js +33 -18
  16. package/dist/scripts/check-ai-disclosure.js.map +1 -1
  17. package/dist/scripts/check-locales.d.ts +2 -0
  18. package/dist/scripts/check-locales.d.ts.map +1 -0
  19. package/dist/scripts/check-locales.js +46 -0
  20. package/dist/scripts/check-locales.js.map +1 -0
  21. package/dist/scripts/lib/locale-files.d.ts +40 -0
  22. package/dist/scripts/lib/locale-files.d.ts.map +1 -0
  23. package/dist/scripts/lib/locale-files.js +181 -0
  24. package/dist/scripts/lib/locale-files.js.map +1 -0
  25. package/dist/scripts/locale-extract.d.ts +2 -0
  26. package/dist/scripts/locale-extract.d.ts.map +1 -0
  27. package/dist/scripts/locale-extract.js +99 -0
  28. package/dist/scripts/locale-extract.js.map +1 -0
  29. package/dist/scripts/locale-merge.d.ts +2 -0
  30. package/dist/scripts/locale-merge.d.ts.map +1 -0
  31. package/dist/scripts/locale-merge.js +114 -0
  32. package/dist/scripts/locale-merge.js.map +1 -0
  33. package/dist/src/auth/middleware.d.ts +6 -0
  34. package/dist/src/auth/middleware.d.ts.map +1 -1
  35. package/dist/src/auth/middleware.js +24 -4
  36. package/dist/src/auth/middleware.js.map +1 -1
  37. package/dist/src/cli/connect/mcp/tools/agent-management.d.ts +1 -0
  38. package/dist/src/cli/connect/mcp/tools/agent-management.d.ts.map +1 -1
  39. package/dist/src/cli/connect/mcp/tools/agent-management.js +9 -0
  40. package/dist/src/cli/connect/mcp/tools/agent-management.js.map +1 -1
  41. package/dist/src/cli/connect/mcp/tools/schedules.d.ts +3 -0
  42. package/dist/src/cli/connect/mcp/tools/schedules.d.ts.map +1 -1
  43. package/dist/src/cli/connect/mcp/tools/schedules.js +5 -0
  44. package/dist/src/cli/connect/mcp/tools/schedules.js.map +1 -1
  45. package/dist/src/cli/connect/tool-call-defs-agent.d.ts +2 -0
  46. package/dist/src/cli/connect/tool-call-defs-agent.d.ts.map +1 -1
  47. package/dist/src/cli/connect/tool-call-defs-agent.js +16 -0
  48. package/dist/src/cli/connect/tool-call-defs-agent.js.map +1 -1
  49. package/dist/src/cli/connect/tool-call-defs-organism.d.ts.map +1 -1
  50. package/dist/src/cli/connect/tool-call-defs-organism.js +6 -0
  51. package/dist/src/cli/connect/tool-call-defs-organism.js.map +1 -1
  52. package/dist/src/config-operator.d.ts +4 -1
  53. package/dist/src/config-operator.d.ts.map +1 -1
  54. package/dist/src/config-operator.js +4 -4
  55. package/dist/src/config-operator.js.map +1 -1
  56. package/dist/src/data/library-packs/cortex.d.ts +3 -0
  57. package/dist/src/data/library-packs/cortex.d.ts.map +1 -1
  58. package/dist/src/data/library-packs/cortex.js +58 -0
  59. package/dist/src/data/library-packs/cortex.js.map +1 -1
  60. package/dist/src/generated/api-types.d.ts +90 -2
  61. package/dist/src/generated/api-types.d.ts.map +1 -1
  62. package/dist/src/i18n.d.ts +1 -1
  63. package/dist/src/i18n.d.ts.map +1 -1
  64. package/dist/src/i18n.js +13 -6
  65. package/dist/src/i18n.js.map +1 -1
  66. package/dist/src/mcp/agent-management.d.ts +6 -2
  67. package/dist/src/mcp/agent-management.d.ts.map +1 -1
  68. package/dist/src/mcp/agent-management.js +34 -3
  69. package/dist/src/mcp/agent-management.js.map +1 -1
  70. package/dist/src/mcp/agent-schedules.d.ts +6 -2
  71. package/dist/src/mcp/agent-schedules.d.ts.map +1 -1
  72. package/dist/src/mcp/agent-schedules.js +22 -1
  73. package/dist/src/mcp/agent-schedules.js.map +1 -1
  74. package/dist/src/mcp/annotations.d.ts +1 -0
  75. package/dist/src/mcp/annotations.d.ts.map +1 -1
  76. package/dist/src/mcp/annotations.js +5 -0
  77. package/dist/src/mcp/annotations.js.map +1 -1
  78. package/dist/src/mcp/catalog/definitions/agent-messaging.d.ts +2 -0
  79. package/dist/src/mcp/catalog/definitions/agent-messaging.d.ts.map +1 -1
  80. package/dist/src/mcp/catalog/definitions/agent-messaging.js +12 -0
  81. package/dist/src/mcp/catalog/definitions/agent-messaging.js.map +1 -1
  82. package/dist/src/mcp/catalog/definitions/schedules-tasks-memory.d.ts.map +1 -1
  83. package/dist/src/mcp/catalog/definitions/schedules-tasks-memory.js +7 -0
  84. package/dist/src/mcp/catalog/definitions/schedules-tasks-memory.js.map +1 -1
  85. package/dist/src/mcp/catalog/scopes.d.ts.map +1 -1
  86. package/dist/src/mcp/catalog/scopes.js +5 -0
  87. package/dist/src/mcp/catalog/scopes.js.map +1 -1
  88. package/dist/src/mcp/catalog/surfaces.d.ts.map +1 -1
  89. package/dist/src/mcp/catalog/surfaces.js +4 -2
  90. package/dist/src/mcp/catalog/surfaces.js.map +1 -1
  91. package/dist/src/routes/admin-features.d.ts.map +1 -1
  92. package/dist/src/routes/admin-features.js +5 -4
  93. package/dist/src/routes/admin-features.js.map +1 -1
  94. package/dist/src/routes/agents/constants.d.ts +18 -2
  95. package/dist/src/routes/agents/constants.d.ts.map +1 -1
  96. package/dist/src/routes/agents/constants.js +18 -2
  97. package/dist/src/routes/agents/constants.js.map +1 -1
  98. package/dist/src/routes/agents/device-auth.d.ts +2 -0
  99. package/dist/src/routes/agents/device-auth.d.ts.map +1 -1
  100. package/dist/src/routes/agents/device-auth.js +22 -2
  101. package/dist/src/routes/agents/device-auth.js.map +1 -1
  102. package/dist/src/routes/agents/management.d.ts +7 -0
  103. package/dist/src/routes/agents/management.d.ts.map +1 -1
  104. package/dist/src/routes/agents/management.js +59 -4
  105. package/dist/src/routes/agents/management.js.map +1 -1
  106. package/dist/src/routes/agents/profile-metadata.d.ts +4 -0
  107. package/dist/src/routes/agents/profile-metadata.d.ts.map +1 -1
  108. package/dist/src/routes/agents/profile-metadata.js +31 -2
  109. package/dist/src/routes/agents/profile-metadata.js.map +1 -1
  110. package/dist/src/routes/agents/registration.d.ts +2 -0
  111. package/dist/src/routes/agents/registration.d.ts.map +1 -1
  112. package/dist/src/routes/agents/registration.js +3 -0
  113. package/dist/src/routes/agents/registration.js.map +1 -1
  114. package/dist/src/routes/ai-transparency.d.ts.map +1 -1
  115. package/dist/src/routes/ai-transparency.js +5 -1
  116. package/dist/src/routes/ai-transparency.js.map +1 -1
  117. package/dist/src/routes/auth.d.ts +3 -0
  118. package/dist/src/routes/auth.d.ts.map +1 -1
  119. package/dist/src/routes/auth.js +22 -5
  120. package/dist/src/routes/auth.js.map +1 -1
  121. package/dist/src/routes/portal.d.ts +3 -0
  122. package/dist/src/routes/portal.d.ts.map +1 -1
  123. package/dist/src/routes/portal.js +39 -17
  124. package/dist/src/routes/portal.js.map +1 -1
  125. package/dist/src/routes/schedules.d.ts +3 -0
  126. package/dist/src/routes/schedules.d.ts.map +1 -1
  127. package/dist/src/routes/schedules.js +16 -29
  128. package/dist/src/routes/schedules.js.map +1 -1
  129. package/dist/src/services/agent-profile-write.d.ts +13 -1
  130. package/dist/src/services/agent-profile-write.d.ts.map +1 -1
  131. package/dist/src/services/agent-profile-write.js +55 -0
  132. package/dist/src/services/agent-profile-write.js.map +1 -1
  133. package/dist/src/services/build-app-prompt.d.ts.map +1 -1
  134. package/dist/src/services/build-app-prompt.js +22 -0
  135. package/dist/src/services/build-app-prompt.js.map +1 -1
  136. package/dist/src/services/email-templates.d.ts.map +1 -1
  137. package/dist/src/services/email-templates.js +79 -3
  138. package/dist/src/services/email-templates.js.map +1 -1
  139. package/dist/src/services/notification-templates.d.ts +5 -3
  140. package/dist/src/services/notification-templates.d.ts.map +1 -1
  141. package/dist/src/services/notification-templates.js +23 -3
  142. package/dist/src/services/notification-templates.js.map +1 -1
  143. package/dist/src/services/schedule-write.d.ts +25 -2
  144. package/dist/src/services/schedule-write.d.ts.map +1 -1
  145. package/dist/src/services/schedule-write.js +44 -0
  146. package/dist/src/services/schedule-write.js.map +1 -1
  147. package/dist/src/storage/providers/postgres-kysely/db-types.d.ts +2 -0
  148. package/dist/src/storage/providers/postgres-kysely/db-types.d.ts.map +1 -1
  149. package/dist/src/storage/providers/postgres-kysely/methods/identity.d.ts.map +1 -1
  150. package/dist/src/storage/providers/postgres-kysely/methods/identity.js +5 -1
  151. package/dist/src/storage/providers/postgres-kysely/methods/identity.js.map +1 -1
  152. package/dist/src/storage/providers/postgres-kysely/methods/sessions.d.ts +1 -0
  153. package/dist/src/storage/providers/postgres-kysely/methods/sessions.d.ts.map +1 -1
  154. package/dist/src/storage/providers/postgres-kysely/methods/sessions.js +4 -0
  155. package/dist/src/storage/providers/postgres-kysely/methods/sessions.js.map +1 -1
  156. package/dist/src/storage/providers/postgres-kysely/migrations/0034_agent_console_url.sql +16 -0
  157. package/dist/src/storage/providers/postgres-kysely/migrations/0035_agent_registered_by.sql +19 -0
  158. package/dist/src/storage/providers/sqlite/methods/extensions-notify.d.ts +2 -0
  159. package/dist/src/storage/providers/sqlite/methods/extensions-notify.d.ts.map +1 -1
  160. package/dist/src/storage/providers/sqlite/methods/extensions-notify.js +4 -0
  161. package/dist/src/storage/providers/sqlite/methods/extensions-notify.js.map +1 -1
  162. package/dist/src/storage/providers/sqlite/methods/owner.d.ts +5 -0
  163. package/dist/src/storage/providers/sqlite/methods/owner.d.ts.map +1 -1
  164. package/dist/src/storage/providers/sqlite/methods/owner.js +13 -4
  165. package/dist/src/storage/providers/sqlite/methods/owner.js.map +1 -1
  166. package/dist/src/storage/providers/sqlite/repos/auth.d.ts +2 -0
  167. package/dist/src/storage/providers/sqlite/repos/auth.d.ts.map +1 -1
  168. package/dist/src/storage/providers/sqlite/repos/auth.js +4 -0
  169. package/dist/src/storage/providers/sqlite/repos/auth.js.map +1 -1
  170. package/dist/src/storage/providers/sqlite/schema-tables-1.d.ts +3 -0
  171. package/dist/src/storage/providers/sqlite/schema-tables-1.d.ts.map +1 -1
  172. package/dist/src/storage/providers/sqlite/schema-tables-1.js +4 -2
  173. package/dist/src/storage/providers/sqlite/schema-tables-1.js.map +1 -1
  174. package/dist/src/storage/providers/sqlite/schema-tables-2.d.ts +3 -0
  175. package/dist/src/storage/providers/sqlite/schema-tables-2.d.ts.map +1 -1
  176. package/dist/src/storage/providers/sqlite/schema-tables-2.js +4 -3
  177. package/dist/src/storage/providers/sqlite/schema-tables-2.js.map +1 -1
  178. package/dist/src/storage/providers/sqlite/schema-tables-3.d.ts +3 -0
  179. package/dist/src/storage/providers/sqlite/schema-tables-3.d.ts.map +1 -1
  180. package/dist/src/storage/providers/sqlite/schema-tables-3.js +6 -9
  181. package/dist/src/storage/providers/sqlite/schema-tables-3.js.map +1 -1
  182. package/dist/src/storage/providers/sqlite/schema.d.ts +6 -0
  183. package/dist/src/storage/providers/sqlite/schema.d.ts.map +1 -1
  184. package/dist/src/storage/providers/sqlite/schema.js +9 -0
  185. package/dist/src/storage/providers/sqlite/schema.js.map +1 -1
  186. package/dist/src/storage/repositories/session.repository.d.ts +14 -0
  187. package/dist/src/storage/repositories/session.repository.d.ts.map +1 -1
  188. package/dist/src/storage/repositories/session.repository.js +2 -0
  189. package/dist/src/storage/repositories/session.repository.js.map +1 -1
  190. package/dist/src/storage/storage-factory.d.ts +6 -0
  191. package/dist/src/storage/storage-factory.d.ts.map +1 -1
  192. package/dist/src/storage/storage-factory.js +38 -19
  193. package/dist/src/storage/storage-factory.js.map +1 -1
  194. package/dist/src/storage/types/identity.d.ts +31 -0
  195. package/dist/src/storage/types/identity.d.ts.map +1 -1
  196. package/dist/src/utils/gaii.d.ts +11 -0
  197. package/dist/src/utils/gaii.d.ts.map +1 -1
  198. package/dist/src/utils/gaii.js +16 -0
  199. package/dist/src/utils/gaii.js.map +1 -1
  200. package/dist/static/sdk-libs/ai/strings.js +5 -2
  201. package/dist/static/sdk-libs/auth/i18n.js +9 -4
  202. package/dist/static/sdk-libs/auth/modal.js +11 -3
  203. package/dist/static/sdk-libs/auth/pill-strings.js +52 -0
  204. package/dist/static/sdk-libs/auth/pill.js +16 -2
  205. package/dist/static/sdk-libs/dist/aimeat-ai.js +30 -1
  206. package/dist/static/sdk-libs/dist/aimeat-auth.js +61 -5
  207. package/dist/static/sdk-libs/dist/aimeat-game.js +32 -2
  208. package/dist/static/sdk-libs/dist/aimeat-header.js +9 -4
  209. package/dist/static/sdk-libs/game/i18n.js +38 -7
  210. package/dist/static/sdk-libs/header/index.js +11 -5
  211. package/package.json +4 -1
@@ -1,288 +1,296 @@
1
- /**
2
- * @file public/views/profile/agents/scope-model.js
3
- * @description What an agent's scope set IS and how the checkbox editor converts to and from it —
4
- * the pure half of scope-config.js, with no imports, so it can be executed by a unit test.
5
- *
6
- * It is separate for one reason: the editor's expand/collapse pair is a round trip, and a round
7
- * trip that silently loses a member is invisible in every screenshot. `*` used to expand into
8
- * every checkbox and a fully-ticked editor used to collapse back to `['*']`, so
9
- * `memory:write-reserved` — which the server grants on the exact string only — always LOOKED
10
- * granted and could never be SAVED. Both halves have to agree about what the wildcard carries,
11
- * and now they agree here, once, under test/unit/agent-scope-model.test.ts.
12
- * @structure NOT_IN_WILDCARD · SCOPE_DOMAINS · SCOPE_TEMPLATES · wildcardScopes · expandScopes ·
13
- * collapseScopes · bulkScopes · detectTemplate
14
- * @usage
15
- * import { expandScopes, collapseScopes } from './scope-model.js';
16
- * const checked = expandScopes(agent.default_scopes ?? ['*']);
17
- * await save(collapseScopes(checked));
18
- * @version-history
19
- * v1.3.0 — 2026-08-11account:security, a sixth word outside the wildcard: the password, the
20
- * recovery address, the second factor, the identity proof, and deleting or exporting the whole
21
- * account. Its own domain, because it is not a permission over the person's data but over
22
- * whether they still reach it.
23
- * v1.2.0 2026-08-11 Five words that no wildcard carries: commerce:psp,
24
- * commerce:beneficiary-verify, agent:permissions, consent:groups and social:members. Each names
25
- * a call the web door reserves to a logged-in person, so it costs its own tick rather than
26
- * riding along with Full access.
27
- * v1.1.0 — 2026-08-10 — Three new domains (agent, app, capability) plus organism:write,
28
- * organism:invite and ext:invoke. These name things an agent could already do with no
29
- * permission at all, because the MCP scope table read silence as consent.
30
- * v1.0.0 — 2026-08-08Split out of scope-config.js so the wildcard round trip is testable.
31
- * v1.1.0 2026-08-08 The vocabulary caught up with the node, and the round trip stopped losing
32
- * things. 17 scopes the node enforces had no row bookkeeping, outgoing email, automations,
33
- * connected accounts, the marketplace so unticking one unrelated box dropped the agent out of
34
- * '*' and revoked every one of them at once, none of them named anywhere in the dialog. Four
35
- * boxes granted nothing (generator:*, agent:register) and one more (task:manage) was dead too.
36
- * Anything the editor does not recognise is now carried through instead of discarded, an
37
- * unknown domain wildcard no longer collapses the whole grant to ['catalogue:read'], a domain
38
- * wildcard the agent already held is re-emitted when that domain was untouched, and an empty
39
- * scope list no longer reports itself as full access.
40
- */
41
-
42
- /**
43
- * Scopes that `*` deliberately does NOT carry, mirroring the server: hasWriteReserved() in
44
- * src/routes/memory/owner-target.ts tests the EXACT string, while every other scope check honours
45
- * the wildcard. "Full access" is one click, and nobody clicking it is deciding that an agent may
46
- * point their decrypted AI key at a URL of its choosing — so that one costs its own tick.
47
- */
48
- export const NOT_IN_WILDCARD = [
49
- 'memory:write-reserved',
50
- // ── Added 2026-08-11 ─────────────────────────────────────────────────────────────────────────
51
- // Eight tool calls the web door reserves to a logged-in person and the agent surface allowed on
52
- // an ordinary permission. Each is the owner's money or who else sees their data. The agent is not
53
- // shut out it should be able to do what a person can — but each of these costs its own tick, so
54
- // it cannot ride along with "Full access".
55
- 'commerce:psp',
56
- 'commerce:beneficiary-verify',
57
- 'agent:permissions',
58
- 'consent:groups',
59
- // Handing another account a standing right to read part of the owner's memory. Same reasoning:
60
- // assembling an audience and giving it a key space are separate acts, and only the second gives
61
- // anything away.
62
- 'share:manage',
63
- 'social:members',
64
- // ── Added 2026-08-11 ─────────────────────────────────────────────────────────────────────────
65
- // The account itself: the password, the recovery address, the second factor, the identity proof,
66
- // and deleting or exporting everything. An agent that can set the password can become the person,
67
- // so this one is never granted by a preset, a bulk header or Full access — only by this box.
68
- 'account:security',
69
- ];
70
-
71
- /**
72
- * The vocabulary of the checkbox editor.
73
- *
74
- * The rule for what belongs here: **ticking or unticking it must change what THIS AGENT can do.**
75
- * That is stricter than "the node reads the string somewhere", and the difference is not academic —
76
- * an audit of all 49 scope strings the node checks found eight where the tick binds someone else or
77
- * nobody:
78
- *
79
- * contract:spend only an app session is ever refused (services/metered-access.ts:151)
80
- * events:emit requireRole('ecosystem') runs first, so never an agent
81
- * organism:write requireRoleOrScope('agent', …) — an agent passes by ROLE, tick or no tick
82
- * task:manage nothing in src/ reads it at all
83
- * agent:register nothing in src/ reads it at all
84
- * generator:* the Generator was removed 2026-07-18; three dead strings
85
- *
86
- * A box that grants nothing is worse than a missing box: it answers the owner's question wrongly.
87
- * The dead ones were removed rather than relabelled; an agent that still holds one now shows it in
88
- * the editor's "other permissions" section, where it can be unticked (see unknownScopes).
89
- */
90
- export const SCOPE_DOMAINS = [
91
- // write-as-owner: write into the OWNER's namespace instead of the agent's own. Same shape as
92
- // messages:send-as-owner below — an explicit, owner-granted delegation. The agent must ALSO
93
- // ask for it per call, so granting it changes nothing on its own.
94
- // write-reserved: ALSO the keys the server itself trusts (openrouter.* / ai-usage.* /
95
- // profile.*). For an agent that administers the account. Not in '*' on purpose — see
96
- // WRITE_RESERVED_SCOPE in src/routes/memory/owner-target.ts. It is a MODIFIER on write-as-owner:
97
- // on its own it lifts a block on writes the agent is not making.
98
- { key: 'memory', permissions: ['read', 'write', 'delete', 'write-as-owner', 'write-reserved'] },
99
- { key: 'storage', permissions: ['read', 'write'] },
100
- { key: 'ai', permissions: ['use'] },
101
- { key: 'work', permissions: ['request', 'read', 'accept', 'publish'] },
102
- // social:members add or remove who may read a shared board. A different promise from posting
103
- // to one, which is what social:write covers.
104
- { key: 'social', permissions: ['read', 'write', 'members'] },
105
- { key: 'messages', permissions: ['send', 'read', 'send-as-owner'] },
106
- { key: 'wallet', permissions: ['read'] },
107
- // consent:groups create a sharing group and decide who is in it. A sharing group IS the
108
- // boundary of who reads the owner's memory, so it is separated from managing consents.
109
- { key: 'consent', permissions: ['manage', 'groups'] },
110
- // Key-space shares: what a sharing group actually reaches. The group is the audience (consent:groups);
111
- // this is what they may read. Without a row here an owner could not grant it in this editor at all.
112
- { key: 'share', permissions: ['manage'] },
113
- { key: 'tunnel', permissions: ['connect'] },
114
- { key: 'catalogue', permissions: ['read'] },
115
- { key: 'task', permissions: ['read', 'write'] },
116
- { key: 'workflow', permissions: ['read', 'write'] },
117
- // The money and the outside world. These were enforced by the node with no row in this editor,
118
- // so an owner could neither grant nor revoke them here while an agent used them daily.
119
- { key: 'finance', permissions: ['read', 'write'] },
120
- { key: 'outbound', permissions: ['send'] },
121
- { key: 'company', permissions: ['read', 'write'] },
122
- { key: 'connections', permissions: ['read', 'write', 'use'] },
123
- { key: 'notifications', permissions: ['send'] },
124
- { key: 'exchange', permissions: ['read', 'write', 'grant', 'beneficiary'] },
125
- // commerce:psp write or destroy the payment credentials money is paid out through.
126
- // commerce:beneficiary-verify record a beneficiary as verified, which is what makes a payout
127
- // possible. Kept apart from the rest because a provider who could open this gate for their own
128
- // payee is not a gate at all.
129
- { key: 'commerce', permissions: ['sell', 'buy', 'psp', 'beneficiary-verify'] },
130
- // organism:read see the organisms and workspaces this agent's owner belongs to
131
- // organism:write — create a workspace, write and publish documents in one, comment, revert
132
- // organism:invite — hand somebody else access: invite a member, grant or revoke a workspace role.
133
- // Separate from write because it changes WHO ELSE can read the owner's knowledge,
134
- // which is a different promise than changing the knowledge.
135
- { key: 'organism', permissions: ['read', 'write', 'invite'] },
136
- { key: 'provenance', permissions: ['write'] },
137
- // cortex:write install/activate/deactivate/delete cortex libraries (browser-side UI)
138
- // ext:write — activate/deactivate/delete extensions, manage instances (server-side WASM)
139
- // Note: extension INSTALL is requireAuth-only at the route (agents can push code that stays
140
- // inert until ext:write activates it). Cortex INSTALL requires cortex:write.
141
- { key: 'cortex', permissions: ['write'] },
142
- { key: 'ext', permissions: ['write', 'invoke'] },
143
-
144
- // ── Added 2026-08-10 (August 2026 audit, step 3a) ────────────────────────────────────────────
145
- // These name things an agent could already do with no permission at all, because the MCP surface
146
- // reads a missing entry in its scope table as permission rather than as an omission. Adding the
147
- // words is what lets an owner see them and take them away.
148
- //
149
- // agent:write reconfigure ANOTHER of the owner's agents: its mode, its tags. Not its own
150
- // record, which needs nothing an agent describing itself is answering about
151
- // itself. This is the one that reaches sideways, at a sibling principal with its
152
- // own identity and its own trust score.
153
- // app:write — publish or update an app under the owner's account, and save or publish a draft.
154
- // app:manage — the destructive half: delete an app, fork one, remove a template. Split from
155
- // write because publishing an update and deleting the thing are different risks.
156
- // capability:write create, update, delete or vouch for a published capability. A capability is
157
- // how this account offers work to others, so writing one speaks in the owner's name.
158
- // agent:permissions rewrite what another of the owner's agents is allowed to do. Separate
159
- // from agent:write, which is its mode and its tags: this one changes the permission set itself.
160
- { key: 'agent', permissions: ['write', 'permissions'] },
161
- { key: 'app', permissions: ['write', 'manage'] },
162
- { key: 'capability', permissions: ['write'] },
163
-
164
- // ── Added 2026-08-11 (August 2026 audit, H-1/H-7) ────────────────────────────────────────────
165
- // account:security — the doors that decide who can sign in as this person: the password, the
166
- // recovery address, the second factor, the identity proof, and deleting or exporting the whole
167
- // account. Enforced by requireOwnerPrincipal() in src/auth/middleware.ts. Nobody was
168
- // grandfathered onto it, so an agent has it only if the owner ticked this box.
169
- { key: 'account', permissions: ['security'] },
170
- ];
171
-
172
- export const SCOPE_TEMPLATES = {
173
- readonly: ['memory:read', 'storage:read', 'catalogue:read', 'social:read'],
174
- standard: ['memory:read', 'memory:write', 'storage:read', 'storage:write', 'catalogue:read', 'social:read', 'work:request', 'work:read'],
175
- full: ['*'],
176
- };
177
-
178
- /** Every scope `*` actually carries — each box in the editor except the out-of-wildcard ones. */
179
- export function wildcardScopes() {
180
- const set = new Set();
181
- for (const d of SCOPE_DOMAINS) {
182
- for (const p of d.permissions) {
183
- const s = `${d.key}:${p}`;
184
- if (!NOT_IN_WILDCARD.includes(s)) set.add(s);
185
- }
186
- }
187
- return set;
188
- }
189
-
190
- /**
191
- * The scopes a domain's "all" header covers. An out-of-wildcard scope is left out on purpose: it
192
- * costs an explicit tick precisely so it cannot ride along with a bulk gesture.
193
- */
194
- export function bulkScopes(domDef) {
195
- return domDef.permissions.map(p => `${domDef.key}:${p}`).filter(s => !NOT_IN_WILDCARD.includes(s));
196
- }
197
-
198
- /** Every scope string the editor has a row for. */
199
- export function knownScopes() {
200
- const set = new Set();
201
- for (const d of SCOPE_DOMAINS) for (const p of d.permissions) set.add(`${d.key}:${p}`);
202
- return set;
203
- }
204
-
205
- /**
206
- * Scopes this agent holds that the editor has no row for — an unknown domain, a scope added to the
207
- * node before the editor caught up, or a malformed string from a hand-written PATCH.
208
- *
209
- * They are surfaced rather than dropped. A scope the owner cannot see is one they cannot revoke,
210
- * and silently discarding it on save revokes it without telling them — both directions are wrong,
211
- * and which one hurts depends only on whether the scope was doing something.
212
- */
213
- export function unknownScopes(scopeList) {
214
- const known = knownScopes();
215
- return scopeList.filter(s => s !== '*' && !known.has(s) && !isKnownDomainWildcard(s));
216
- }
217
-
218
- function isKnownDomainWildcard(s) {
219
- const [domain, perm] = s.split(':');
220
- return perm === '*' && SCOPE_DOMAINS.some(d => d.key === domain);
221
- }
222
-
223
- /** A stored scope array the set of ticked boxes (plus any scope with no box, kept verbatim). */
224
- export function expandScopes(scopeList) {
225
- // `*` first, then the explicit entries on top: an agent can hold ['*', 'memory:write-reserved'],
226
- // and the wildcard must not erase the extra that was granted alongside it.
227
- const set = scopeList.includes('*') ? wildcardScopes() : new Set();
228
- for (const s of scopeList) {
229
- if (s === '*') continue;
230
- const [domain, perm] = s.split(':');
231
- if (perm === '*') {
232
- const domDef = SCOPE_DOMAINS.find(d => d.key === domain);
233
- // A domain wildcard carries no more than the global one does. One for a domain the editor
234
- // does NOT know is kept as-is: dropping it silently revoked a real grant — `['finance:*']`
235
- // used to open-and-save into `['catalogue:read']`.
236
- if (domDef) bulkScopes(domDef).forEach(sc => set.add(sc));
237
- else set.add(s);
238
- } else {
239
- set.add(s);
240
- }
241
- }
242
- return set;
243
- }
244
-
245
- /**
246
- * The ticked boxes → the scope array to store.
247
- *
248
- * Collapses to `*` only over what the wildcard carries, keeps every out-of-wildcard scope spelled
249
- * out beside it, and carries through anything the editor has no row for. `original` is the array
250
- * the dialog opened with: a domain wildcard the agent already held is re-emitted when that domain
251
- * was left alone, so looking at an agent and pressing Save does not quietly rewrite `memory:*` into
252
- * a snapshot that stops covering whatever memory scope the node adds next.
253
- */
254
- export function collapseScopes(checked, original = []) {
255
- const known = knownScopes();
256
- const extras = NOT_IN_WILDCARD.filter(s => checked.has(s));
257
- const carried = [...checked].filter(s => !known.has(s));
258
- if ([...wildcardScopes()].every(s => checked.has(s))) return ['*', ...extras, ...carried];
259
-
260
- const out = [];
261
- const consumed = new Set();
262
- for (const d of SCOPE_DOMAINS) {
263
- const domainWildcard = `${d.key}:*`;
264
- const bulk = bulkScopes(d);
265
- if (original.includes(domainWildcard) && bulk.every(s => checked.has(s))) {
266
- out.push(domainWildcard);
267
- bulk.forEach(s => consumed.add(s));
268
- }
269
- }
270
- for (const s of checked) if (!consumed.has(s)) out.push(s);
271
- return out.length > 0 ? out : ['catalogue:read'];
272
- }
273
-
274
- export function detectTemplate(scopes) {
275
- // A MISSING scope list is the legacy "everything" default (both call sites read
276
- // `default_scopes ?? ['*']`). An EMPTY one is not the same thing, and calling it full access
277
- // labelled a zero-access agent as the maximum grant.
278
- if (!scopes) return 'full';
279
- if (scopes.length === 0) return 'custom';
280
- if (scopes.includes('*')) return 'full';
281
- const sorted = [...scopes].sort();
282
- for (const [name, tpl] of Object.entries(SCOPE_TEMPLATES)) {
283
- if (name === 'full') continue;
284
- const tplSorted = [...tpl].sort();
285
- if (sorted.length === tplSorted.length && sorted.every((s, i) => s === tplSorted[i])) return name;
286
- }
287
- return 'custom';
288
- }
1
+ /**
2
+ * @file public/views/profile/agents/scope-model.js
3
+ * @description What an agent's scope set IS and how the checkbox editor converts to and from it —
4
+ * the pure half of scope-config.js, with no imports, so it can be executed by a unit test.
5
+ *
6
+ * It is separate for one reason: the editor's expand/collapse pair is a round trip, and a round
7
+ * trip that silently loses a member is invisible in every screenshot. `*` used to expand into
8
+ * every checkbox and a fully-ticked editor used to collapse back to `['*']`, so
9
+ * `memory:write-reserved` — which the server grants on the exact string only — always LOOKED
10
+ * granted and could never be SAVED. Both halves have to agree about what the wildcard carries,
11
+ * and now they agree here, once, under test/unit/agent-scope-model.test.ts.
12
+ * @structure NOT_IN_WILDCARD · SCOPE_DOMAINS · SCOPE_TEMPLATES · wildcardScopes · expandScopes ·
13
+ * collapseScopes · bulkScopes · detectTemplate
14
+ * @usage
15
+ * import { expandScopes, collapseScopes } from './scope-model.js';
16
+ * const checked = expandScopes(agent.default_scopes ?? ['*']);
17
+ * await save(collapseScopes(checked));
18
+ * @version-history
19
+ * v1.4.0 — 2026-08-13agent:delete, the word a fleet runtime needs to clear away the agents it
20
+ * created. Inside the wildcard on purpose: the route's second condition (only agents this
21
+ * caller registered) is the real fence, and a box nobody can find protects nothing.
22
+ * v1.3.0 2026-08-11 account:security, a sixth word outside the wildcard: the password, the
23
+ * recovery address, the second factor, the identity proof, and deleting or exporting the whole
24
+ * account. Its own domain, because it is not a permission over the person's data but over
25
+ * whether they still reach it.
26
+ * v1.2.0 2026-08-11 Five words that no wildcard carries: commerce:psp,
27
+ * commerce:beneficiary-verify, agent:permissions, consent:groups and social:members. Each names
28
+ * a call the web door reserves to a logged-in person, so it costs its own tick rather than
29
+ * riding along with Full access.
30
+ * v1.1.0 — 2026-08-10Three new domains (agent, app, capability) plus organism:write,
31
+ * organism:invite and ext:invoke. These name things an agent could already do with no
32
+ * permission at all, because the MCP scope table read silence as consent.
33
+ * v1.0.0 2026-08-08Split out of scope-config.js so the wildcard round trip is testable.
34
+ * v1.1.0 2026-08-08 The vocabulary caught up with the node, and the round trip stopped losing
35
+ * things. 17 scopes the node enforces had no row bookkeeping, outgoing email, automations,
36
+ * connected accounts, the marketplace so unticking one unrelated box dropped the agent out of
37
+ * '*' and revoked every one of them at once, none of them named anywhere in the dialog. Four
38
+ * boxes granted nothing (generator:*, agent:register) and one more (task:manage) was dead too.
39
+ * Anything the editor does not recognise is now carried through instead of discarded, an
40
+ * unknown domain wildcard no longer collapses the whole grant to ['catalogue:read'], a domain
41
+ * wildcard the agent already held is re-emitted when that domain was untouched, and an empty
42
+ * scope list no longer reports itself as full access.
43
+ */
44
+
45
+ /**
46
+ * Scopes that `*` deliberately does NOT carry, mirroring the server: hasWriteReserved() in
47
+ * src/routes/memory/owner-target.ts tests the EXACT string, while every other scope check honours
48
+ * the wildcard. "Full access" is one click, and nobody clicking it is deciding that an agent may
49
+ * point their decrypted AI key at a URL of its choosing — so that one costs its own tick.
50
+ */
51
+ export const NOT_IN_WILDCARD = [
52
+ 'memory:write-reserved',
53
+ // ── Added 2026-08-11 ─────────────────────────────────────────────────────────────────────────
54
+ // Eight tool calls the web door reserves to a logged-in person and the agent surface allowed on
55
+ // an ordinary permission. Each is the owner's money or who else sees their data. The agent is not
56
+ // shut out — it should be able to do what a person can — but each of these costs its own tick, so
57
+ // it cannot ride along with "Full access".
58
+ 'commerce:psp',
59
+ 'commerce:beneficiary-verify',
60
+ 'agent:permissions',
61
+ 'consent:groups',
62
+ // Handing another account a standing right to read part of the owner's memory. Same reasoning:
63
+ // assembling an audience and giving it a key space are separate acts, and only the second gives
64
+ // anything away.
65
+ 'share:manage',
66
+ 'social:members',
67
+ // ── Added 2026-08-11 ─────────────────────────────────────────────────────────────────────────
68
+ // The account itself: the password, the recovery address, the second factor, the identity proof,
69
+ // and deleting or exporting everything. An agent that can set the password can become the person,
70
+ // so this one is never granted by a preset, a bulk header or Full access — only by this box.
71
+ 'account:security',
72
+ ];
73
+
74
+ /**
75
+ * The vocabulary of the checkbox editor.
76
+ *
77
+ * The rule for what belongs here: **ticking or unticking it must change what THIS AGENT can do.**
78
+ * That is stricter than "the node reads the string somewhere", and the difference is not academic —
79
+ * an audit of all 49 scope strings the node checks found eight where the tick binds someone else or
80
+ * nobody:
81
+ *
82
+ * contract:spend only an app session is ever refused (services/metered-access.ts:151)
83
+ * events:emit requireRole('ecosystem') runs first, so never an agent
84
+ * organism:write requireRoleOrScope('agent', …) an agent passes by ROLE, tick or no tick
85
+ * task:manage nothing in src/ reads it at all
86
+ * agent:register nothing in src/ reads it at all
87
+ * generator:* the Generator was removed 2026-07-18; three dead strings
88
+ *
89
+ * A box that grants nothing is worse than a missing box: it answers the owner's question wrongly.
90
+ * The dead ones were removed rather than relabelled; an agent that still holds one now shows it in
91
+ * the editor's "other permissions" section, where it can be unticked (see unknownScopes).
92
+ */
93
+ export const SCOPE_DOMAINS = [
94
+ // write-as-owner: write into the OWNER's namespace instead of the agent's own. Same shape as
95
+ // messages:send-as-owner below an explicit, owner-granted delegation. The agent must ALSO
96
+ // ask for it per call, so granting it changes nothing on its own.
97
+ // write-reserved: ALSO the keys the server itself trusts (openrouter.* / ai-usage.* /
98
+ // profile.*). For an agent that administers the account. Not in '*' on purpose — see
99
+ // WRITE_RESERVED_SCOPE in src/routes/memory/owner-target.ts. It is a MODIFIER on write-as-owner:
100
+ // on its own it lifts a block on writes the agent is not making.
101
+ { key: 'memory', permissions: ['read', 'write', 'delete', 'write-as-owner', 'write-reserved'] },
102
+ { key: 'storage', permissions: ['read', 'write'] },
103
+ { key: 'ai', permissions: ['use'] },
104
+ { key: 'work', permissions: ['request', 'read', 'accept', 'publish'] },
105
+ // social:members add or remove who may read a shared board. A different promise from posting
106
+ // to one, which is what social:write covers.
107
+ { key: 'social', permissions: ['read', 'write', 'members'] },
108
+ { key: 'messages', permissions: ['send', 'read', 'send-as-owner'] },
109
+ { key: 'wallet', permissions: ['read'] },
110
+ // consent:groups create a sharing group and decide who is in it. A sharing group IS the
111
+ // boundary of who reads the owner's memory, so it is separated from managing consents.
112
+ { key: 'consent', permissions: ['manage', 'groups'] },
113
+ // Key-space shares: what a sharing group actually reaches. The group is the audience (consent:groups);
114
+ // this is what they may read. Without a row here an owner could not grant it in this editor at all.
115
+ { key: 'share', permissions: ['manage'] },
116
+ { key: 'tunnel', permissions: ['connect'] },
117
+ { key: 'catalogue', permissions: ['read'] },
118
+ { key: 'task', permissions: ['read', 'write'] },
119
+ { key: 'workflow', permissions: ['read', 'write'] },
120
+ // The money and the outside world. These were enforced by the node with no row in this editor,
121
+ // so an owner could neither grant nor revoke them here while an agent used them daily.
122
+ { key: 'finance', permissions: ['read', 'write'] },
123
+ { key: 'outbound', permissions: ['send'] },
124
+ { key: 'company', permissions: ['read', 'write'] },
125
+ { key: 'connections', permissions: ['read', 'write', 'use'] },
126
+ { key: 'notifications', permissions: ['send'] },
127
+ { key: 'exchange', permissions: ['read', 'write', 'grant', 'beneficiary'] },
128
+ // commerce:psp write or destroy the payment credentials money is paid out through.
129
+ // commerce:beneficiary-verify record a beneficiary as verified, which is what makes a payout
130
+ // possible. Kept apart from the rest because a provider who could open this gate for their own
131
+ // payee is not a gate at all.
132
+ { key: 'commerce', permissions: ['sell', 'buy', 'psp', 'beneficiary-verify'] },
133
+ // organism:read — see the organisms and workspaces this agent's owner belongs to
134
+ // organism:write — create a workspace, write and publish documents in one, comment, revert
135
+ // organism:invite — hand somebody else access: invite a member, grant or revoke a workspace role.
136
+ // Separate from write because it changes WHO ELSE can read the owner's knowledge,
137
+ // which is a different promise than changing the knowledge.
138
+ { key: 'organism', permissions: ['read', 'write', 'invite'] },
139
+ { key: 'provenance', permissions: ['write'] },
140
+ // cortex:write install/activate/deactivate/delete cortex libraries (browser-side UI)
141
+ // ext:write — activate/deactivate/delete extensions, manage instances (server-side WASM)
142
+ // Note: extension INSTALL is requireAuth-only at the route (agents can push code that stays
143
+ // inert until ext:write activates it). Cortex INSTALL requires cortex:write.
144
+ { key: 'cortex', permissions: ['write'] },
145
+ { key: 'ext', permissions: ['write', 'invoke'] },
146
+
147
+ // ── Added 2026-08-10 (August 2026 audit, step 3a) ────────────────────────────────────────────
148
+ // These name things an agent could already do with no permission at all, because the MCP surface
149
+ // reads a missing entry in its scope table as permission rather than as an omission. Adding the
150
+ // words is what lets an owner see them and take them away.
151
+ //
152
+ // agent:write reconfigure ANOTHER of the owner's agents: its mode, its tags. Not its own
153
+ // record, which needs nothing an agent describing itself is answering about
154
+ // itself. This is the one that reaches sideways, at a sibling principal with its
155
+ // own identity and its own trust score.
156
+ // app:write publish or update an app under the owner's account, and save or publish a draft.
157
+ // app:manage — the destructive half: delete an app, fork one, remove a template. Split from
158
+ // write because publishing an update and deleting the thing are different risks.
159
+ // capability:write create, update, delete or vouch for a published capability. A capability is
160
+ // how this account offers work to others, so writing one speaks in the owner's name.
161
+ // agent:permissions rewrite what another of the owner's agents is allowed to do. Separate
162
+ // from agent:write, which is its mode and its tags: this one changes the permission set itself.
163
+ // agent:delete — end another of the owner's agents for good. Its own word rather than part of
164
+ // agent:write, because reconfiguring a sibling and ending it are different sizes of act, and
165
+ // because the caller that needs it is a fleet concierge cleaning up what it created. The route
166
+ // adds a second condition the box cannot express: an agent may only delete an agent whose
167
+ // registration it authorized itself.
168
+ { key: 'agent', permissions: ['write', 'permissions', 'delete'] },
169
+ { key: 'app', permissions: ['write', 'manage'] },
170
+ { key: 'capability', permissions: ['write'] },
171
+
172
+ // ── Added 2026-08-11 (August 2026 audit, H-1/H-7) ────────────────────────────────────────────
173
+ // account:security — the doors that decide who can sign in as this person: the password, the
174
+ // recovery address, the second factor, the identity proof, and deleting or exporting the whole
175
+ // account. Enforced by requireOwnerPrincipal() in src/auth/middleware.ts. Nobody was
176
+ // grandfathered onto it, so an agent has it only if the owner ticked this box.
177
+ { key: 'account', permissions: ['security'] },
178
+ ];
179
+
180
+ export const SCOPE_TEMPLATES = {
181
+ readonly: ['memory:read', 'storage:read', 'catalogue:read', 'social:read'],
182
+ standard: ['memory:read', 'memory:write', 'storage:read', 'storage:write', 'catalogue:read', 'social:read', 'work:request', 'work:read'],
183
+ full: ['*'],
184
+ };
185
+
186
+ /** Every scope `*` actually carries — each box in the editor except the out-of-wildcard ones. */
187
+ export function wildcardScopes() {
188
+ const set = new Set();
189
+ for (const d of SCOPE_DOMAINS) {
190
+ for (const p of d.permissions) {
191
+ const s = `${d.key}:${p}`;
192
+ if (!NOT_IN_WILDCARD.includes(s)) set.add(s);
193
+ }
194
+ }
195
+ return set;
196
+ }
197
+
198
+ /**
199
+ * The scopes a domain's "all" header covers. An out-of-wildcard scope is left out on purpose: it
200
+ * costs an explicit tick precisely so it cannot ride along with a bulk gesture.
201
+ */
202
+ export function bulkScopes(domDef) {
203
+ return domDef.permissions.map(p => `${domDef.key}:${p}`).filter(s => !NOT_IN_WILDCARD.includes(s));
204
+ }
205
+
206
+ /** Every scope string the editor has a row for. */
207
+ export function knownScopes() {
208
+ const set = new Set();
209
+ for (const d of SCOPE_DOMAINS) for (const p of d.permissions) set.add(`${d.key}:${p}`);
210
+ return set;
211
+ }
212
+
213
+ /**
214
+ * Scopes this agent holds that the editor has no row for — an unknown domain, a scope added to the
215
+ * node before the editor caught up, or a malformed string from a hand-written PATCH.
216
+ *
217
+ * They are surfaced rather than dropped. A scope the owner cannot see is one they cannot revoke,
218
+ * and silently discarding it on save revokes it without telling them — both directions are wrong,
219
+ * and which one hurts depends only on whether the scope was doing something.
220
+ */
221
+ export function unknownScopes(scopeList) {
222
+ const known = knownScopes();
223
+ return scopeList.filter(s => s !== '*' && !known.has(s) && !isKnownDomainWildcard(s));
224
+ }
225
+
226
+ function isKnownDomainWildcard(s) {
227
+ const [domain, perm] = s.split(':');
228
+ return perm === '*' && SCOPE_DOMAINS.some(d => d.key === domain);
229
+ }
230
+
231
+ /** A stored scope array → the set of ticked boxes (plus any scope with no box, kept verbatim). */
232
+ export function expandScopes(scopeList) {
233
+ // `*` first, then the explicit entries on top: an agent can hold ['*', 'memory:write-reserved'],
234
+ // and the wildcard must not erase the extra that was granted alongside it.
235
+ const set = scopeList.includes('*') ? wildcardScopes() : new Set();
236
+ for (const s of scopeList) {
237
+ if (s === '*') continue;
238
+ const [domain, perm] = s.split(':');
239
+ if (perm === '*') {
240
+ const domDef = SCOPE_DOMAINS.find(d => d.key === domain);
241
+ // A domain wildcard carries no more than the global one does. One for a domain the editor
242
+ // does NOT know is kept as-is: dropping it silently revoked a real grant — `['finance:*']`
243
+ // used to open-and-save into `['catalogue:read']`.
244
+ if (domDef) bulkScopes(domDef).forEach(sc => set.add(sc));
245
+ else set.add(s);
246
+ } else {
247
+ set.add(s);
248
+ }
249
+ }
250
+ return set;
251
+ }
252
+
253
+ /**
254
+ * The ticked boxes the scope array to store.
255
+ *
256
+ * Collapses to `*` only over what the wildcard carries, keeps every out-of-wildcard scope spelled
257
+ * out beside it, and carries through anything the editor has no row for. `original` is the array
258
+ * the dialog opened with: a domain wildcard the agent already held is re-emitted when that domain
259
+ * was left alone, so looking at an agent and pressing Save does not quietly rewrite `memory:*` into
260
+ * a snapshot that stops covering whatever memory scope the node adds next.
261
+ */
262
+ export function collapseScopes(checked, original = []) {
263
+ const known = knownScopes();
264
+ const extras = NOT_IN_WILDCARD.filter(s => checked.has(s));
265
+ const carried = [...checked].filter(s => !known.has(s));
266
+ if ([...wildcardScopes()].every(s => checked.has(s))) return ['*', ...extras, ...carried];
267
+
268
+ const out = [];
269
+ const consumed = new Set();
270
+ for (const d of SCOPE_DOMAINS) {
271
+ const domainWildcard = `${d.key}:*`;
272
+ const bulk = bulkScopes(d);
273
+ if (original.includes(domainWildcard) && bulk.every(s => checked.has(s))) {
274
+ out.push(domainWildcard);
275
+ bulk.forEach(s => consumed.add(s));
276
+ }
277
+ }
278
+ for (const s of checked) if (!consumed.has(s)) out.push(s);
279
+ return out.length > 0 ? out : ['catalogue:read'];
280
+ }
281
+
282
+ export function detectTemplate(scopes) {
283
+ // A MISSING scope list is the legacy "everything" default (both call sites read
284
+ // `default_scopes ?? ['*']`). An EMPTY one is not the same thing, and calling it full access
285
+ // labelled a zero-access agent as the maximum grant.
286
+ if (!scopes) return 'full';
287
+ if (scopes.length === 0) return 'custom';
288
+ if (scopes.includes('*')) return 'full';
289
+ const sorted = [...scopes].sort();
290
+ for (const [name, tpl] of Object.entries(SCOPE_TEMPLATES)) {
291
+ if (name === 'full') continue;
292
+ const tplSorted = [...tpl].sort();
293
+ if (sorted.length === tplSorted.length && sorted.every((s, i) => s === tplSorted[i])) return name;
294
+ }
295
+ return 'custom';
296
+ }