@sema-agent/settings-schema 1.0.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 (56) hide show
  1. package/CHANGELOG.md +728 -0
  2. package/LICENSE +103 -0
  3. package/README.md +129 -0
  4. package/dist/api/auth-bridge.d.ts +331 -0
  5. package/dist/api/auth-bridge.js +210 -0
  6. package/dist/api/auth.d.ts +216 -0
  7. package/dist/api/auth.js +138 -0
  8. package/dist/api/scopes.d.ts +344 -0
  9. package/dist/api/scopes.js +222 -0
  10. package/dist/api/wire.d.ts +60 -0
  11. package/dist/api/wire.js +89 -0
  12. package/dist/bundle.d.ts +13 -0
  13. package/dist/bundle.js +67 -0
  14. package/dist/config-fns.d.ts +318 -0
  15. package/dist/config-fns.js +472 -0
  16. package/dist/cross-domain.d.ts +34 -0
  17. package/dist/cross-domain.js +118 -0
  18. package/dist/file-edit.d.ts +36 -0
  19. package/dist/file-edit.js +125 -0
  20. package/dist/file-store.d.ts +89 -0
  21. package/dist/file-store.js +238 -0
  22. package/dist/fleet.d.ts +498 -0
  23. package/dist/fleet.js +317 -0
  24. package/dist/hash.d.ts +32 -0
  25. package/dist/hash.js +59 -0
  26. package/dist/hooks.d.ts +5477 -0
  27. package/dist/hooks.js +627 -0
  28. package/dist/index.d.ts +22 -0
  29. package/dist/index.js +26 -0
  30. package/dist/local-load.d.ts +42 -0
  31. package/dist/local-load.js +172 -0
  32. package/dist/migrate.d.ts +255 -0
  33. package/dist/migrate.js +542 -0
  34. package/dist/node.d.ts +11 -0
  35. package/dist/node.js +11 -0
  36. package/dist/reader.d.ts +18 -0
  37. package/dist/reader.js +1 -0
  38. package/dist/remote-exec.d.ts +274 -0
  39. package/dist/remote-exec.js +182 -0
  40. package/dist/resolve-roster.d.ts +28 -0
  41. package/dist/resolve-roster.js +108 -0
  42. package/dist/safety-merge-spec.d.ts +327 -0
  43. package/dist/safety-merge-spec.js +70 -0
  44. package/dist/scheduler-store-node.d.ts +72 -0
  45. package/dist/scheduler-store-node.js +119 -0
  46. package/dist/scheduler-store.d.ts +67 -0
  47. package/dist/scheduler-store.js +89 -0
  48. package/dist/secret-refs.d.ts +33 -0
  49. package/dist/secret-refs.js +48 -0
  50. package/dist/sha256.d.ts +16 -0
  51. package/dist/sha256.js +114 -0
  52. package/dist/skills-manifest.d.ts +12 -0
  53. package/dist/skills-manifest.js +54 -0
  54. package/dist/types.d.ts +13560 -0
  55. package/dist/types.js +2118 -0
  56. package/package.json +138 -0
@@ -0,0 +1,344 @@
1
+ /**
2
+ * `api/scopes` — the MULTI-TENANT SCOPE wire contract for the registry (/api/v1/scopes* +
3
+ * /api/v1/auth/scope). Frozen here so sema-registry (the implementation), the orchestrator, workers and
4
+ * the sema shell import ONE source of truth — same "契约先行" discipline as `api/auth`.
5
+ *
6
+ * THE SCOPE MODEL (normative — the registry implements EXACTLY this):
7
+ *
8
+ * - A **scope** is a tenant boundary: `{id, name, createdAt}`. `id` is a slug (SCOPE_ID_REGEX).
9
+ * The reserved id `"global"` (GLOBAL_SCOPE) is the DEFAULT scope: it always exists, is never
10
+ * created via the API (ScopeCreateRequest rejects it) and is never deleted.
11
+ * - **Membership** is per-scope: `{scopeId, principal, role}` where role is the existing 4-level
12
+ * RBAC ladder (viewer < editor < publisher < admin). The SAME principal may be `editor` in scope A
13
+ * and `viewer` in scope B.
14
+ * - The **instance-level** `users.role` is unchanged and orthogonal: an instance `admin` administers
15
+ * ALL scopes — inside any scope their effective role is always `"admin"` (see effectiveScopeRole).
16
+ * - The **access JWT** gains an OPTIONAL `scope` claim (SCOPE_CLAIM) binding the token to its active
17
+ * scope. A token WITHOUT the claim means `"global"` (tokenScopeOf) — full backward compatibility:
18
+ * every pre-scope token keeps working, scoped to global. The refresh token is NOT scope-bound;
19
+ * refresh re-mints per resolveTokenScope, and the client re-switches if it wants another scope.
20
+ *
21
+ * ENDPOINTS (paths in SCOPES_V1_PATHS / scopeMembersPath):
22
+ *
23
+ * GET /api/v1/scopes → ScopesListResponse {scopes:[{id,name,role}]} — the CALLER's
24
+ * scopes with their per-scope effective role (instance admin sees ALL scopes, role "admin").
25
+ * POST /api/v1/auth/scope → switch active scope: ScopeSwitchRequest {scope} →
26
+ * ScopeSwitchResponse {access_token, token_type:"Bearer", expires_in, scope, role}; errors are
27
+ * OAuth-style OAuthErrorResponse (`invalid_request` = malformed / unknown scope shape,
28
+ * `access_denied` = caller is not a member and not an instance admin — canSwitchScope).
29
+ * Auth: a valid access token (any scope). Mints a NEW access token bound to `scope`; the
30
+ * caller's refresh grant is untouched.
31
+ * POST /api/v1/scopes → create a scope (instance admin only): ScopeCreateRequest
32
+ * {id,name} → ScopeCreateResponse (the created Scope).
33
+ * PUT /api/v1/scopes/{id}/members → upsert a member (instance admin only): ScopeMemberUpsertRequest
34
+ * {principal, role} → ScopeMemberUpsertResponse (the stored membership row).
35
+ * DELETE /api/v1/scopes/{id}/members → remove a member (instance admin only): ScopeMemberDeleteRequest
36
+ * {principal} → ScopeMemberDeleteResponse {ok:true} (idempotent — removing a non-member is 200).
37
+ *
38
+ * WIRE CASING: /api/v1/auth/scope is part of the OAuth face — snake_case on the wire (access_token,
39
+ * expires_in), matching `api/auth`. The /api/v1/scopes management face uses this package's camelCase
40
+ * (createdAt, scopeId) — it is a registry management API, not an OAuth endpoint.
41
+ *
42
+ * Pure zod contract + pure decision functions — zero IO, browser-safe. The decision functions
43
+ * (resolveTokenScope / canSwitchScope / effectiveScopeRole) are THE semantics: the registry
44
+ * implementation imports and calls these — it must not re-implement them.
45
+ */
46
+ import { z } from "zod";
47
+ import { type UserRole } from "../config-fns.js";
48
+ /** The reserved default scope. Always exists; every token without a `scope` claim lives here. */
49
+ export declare const GLOBAL_SCOPE = "global";
50
+ /** Scope ids that can never be created (or deleted) through the API. */
51
+ export declare const RESERVED_SCOPE_IDS: readonly ["global"];
52
+ /** Slug shape for scope ids: lowercase alphanumeric + inner dashes, 1–64 chars, no leading/trailing dash. */
53
+ export declare const SCOPE_ID_REGEX: RegExp;
54
+ export { SCOPES_V1_PATHS, SCOPE_CLAIM } from "./wire.js";
55
+ /** `/api/v1/scopes/{id}/members` for PUT (upsert) and DELETE (remove). */
56
+ export declare function scopeMembersPath(scopeId: string): string;
57
+ /** True for ids the API refuses to create/delete ("global"). */
58
+ export declare function isReservedScopeId(id: string): boolean;
59
+ /** A scope id on the wire — slug-validated. (The reserved "global" IS a valid ScopeId: it exists as an
60
+ * entity everywhere except the create endpoint.) */
61
+ export declare const ScopeId: z.ZodString;
62
+ export type ScopeId = z.infer<typeof ScopeId>;
63
+ /** The per-scope role ladder = the existing instance RBAC ladder (viewer < editor < publisher < admin). */
64
+ export declare const ScopeRole: z.ZodEnum<[UserRole, ...UserRole[]]>;
65
+ export type ScopeRole = UserRole;
66
+ /** The scope entity. */
67
+ export declare const Scope: z.ZodObject<{
68
+ id: z.ZodString;
69
+ /** Human display name (non-unique, non-secret). */
70
+ name: z.ZodString;
71
+ /** Creation time, epoch MILLISECONDS. The reserved "global" scope reports its provisioning time. */
72
+ createdAt: z.ZodNumber;
73
+ }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
74
+ id: z.ZodString;
75
+ /** Human display name (non-unique, non-secret). */
76
+ name: z.ZodString;
77
+ /** Creation time, epoch MILLISECONDS. The reserved "global" scope reports its provisioning time. */
78
+ createdAt: z.ZodNumber;
79
+ }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
80
+ id: z.ZodString;
81
+ /** Human display name (non-unique, non-secret). */
82
+ name: z.ZodString;
83
+ /** Creation time, epoch MILLISECONDS. The reserved "global" scope reports its provisioning time. */
84
+ createdAt: z.ZodNumber;
85
+ }, z.ZodTypeAny, "passthrough">>;
86
+ export type Scope = z.infer<typeof Scope>;
87
+ /** One membership row: `principal` holds `role` inside `scopeId`. Per-scope — the same principal may
88
+ * hold different roles in different scopes. */
89
+ export declare const ScopeMember: z.ZodObject<{
90
+ scopeId: z.ZodString;
91
+ /** The user identity — same principal string the JWT `sub` / x-agent-principal pipeline uses. */
92
+ principal: z.ZodString;
93
+ role: z.ZodEnum<[UserRole, ...UserRole[]]>;
94
+ }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
95
+ scopeId: z.ZodString;
96
+ /** The user identity — same principal string the JWT `sub` / x-agent-principal pipeline uses. */
97
+ principal: z.ZodString;
98
+ role: z.ZodEnum<[UserRole, ...UserRole[]]>;
99
+ }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
100
+ scopeId: z.ZodString;
101
+ /** The user identity — same principal string the JWT `sub` / x-agent-principal pipeline uses. */
102
+ principal: z.ZodString;
103
+ role: z.ZodEnum<[UserRole, ...UserRole[]]>;
104
+ }, z.ZodTypeAny, "passthrough">>;
105
+ export type ScopeMember = z.infer<typeof ScopeMember>;
106
+ /** One list entry: a scope the caller can act in, with the caller's EFFECTIVE role there
107
+ * (effectiveScopeRole — instance admins list every scope with role "admin"). */
108
+ export declare const ScopeListEntry: z.ZodObject<{
109
+ id: z.ZodString;
110
+ name: z.ZodString;
111
+ role: z.ZodEnum<[UserRole, ...UserRole[]]>;
112
+ }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
113
+ id: z.ZodString;
114
+ name: z.ZodString;
115
+ role: z.ZodEnum<[UserRole, ...UserRole[]]>;
116
+ }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
117
+ id: z.ZodString;
118
+ name: z.ZodString;
119
+ role: z.ZodEnum<[UserRole, ...UserRole[]]>;
120
+ }, z.ZodTypeAny, "passthrough">>;
121
+ export type ScopeListEntry = z.infer<typeof ScopeListEntry>;
122
+ export declare const ScopesListResponse: z.ZodObject<{
123
+ scopes: z.ZodArray<z.ZodObject<{
124
+ id: z.ZodString;
125
+ name: z.ZodString;
126
+ role: z.ZodEnum<[UserRole, ...UserRole[]]>;
127
+ }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
128
+ id: z.ZodString;
129
+ name: z.ZodString;
130
+ role: z.ZodEnum<[UserRole, ...UserRole[]]>;
131
+ }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
132
+ id: z.ZodString;
133
+ name: z.ZodString;
134
+ role: z.ZodEnum<[UserRole, ...UserRole[]]>;
135
+ }, z.ZodTypeAny, "passthrough">>, "many">;
136
+ }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
137
+ scopes: z.ZodArray<z.ZodObject<{
138
+ id: z.ZodString;
139
+ name: z.ZodString;
140
+ role: z.ZodEnum<[UserRole, ...UserRole[]]>;
141
+ }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
142
+ id: z.ZodString;
143
+ name: z.ZodString;
144
+ role: z.ZodEnum<[UserRole, ...UserRole[]]>;
145
+ }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
146
+ id: z.ZodString;
147
+ name: z.ZodString;
148
+ role: z.ZodEnum<[UserRole, ...UserRole[]]>;
149
+ }, z.ZodTypeAny, "passthrough">>, "many">;
150
+ }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
151
+ scopes: z.ZodArray<z.ZodObject<{
152
+ id: z.ZodString;
153
+ name: z.ZodString;
154
+ role: z.ZodEnum<[UserRole, ...UserRole[]]>;
155
+ }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
156
+ id: z.ZodString;
157
+ name: z.ZodString;
158
+ role: z.ZodEnum<[UserRole, ...UserRole[]]>;
159
+ }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
160
+ id: z.ZodString;
161
+ name: z.ZodString;
162
+ role: z.ZodEnum<[UserRole, ...UserRole[]]>;
163
+ }, z.ZodTypeAny, "passthrough">>, "many">;
164
+ }, z.ZodTypeAny, "passthrough">>;
165
+ export type ScopesListResponse = z.infer<typeof ScopesListResponse>;
166
+ /** Request: the target scope. Errors: `invalid_request` (malformed), `access_denied` (not a member and
167
+ * not an instance admin — canSwitchScope false). */
168
+ export declare const ScopeSwitchRequest: z.ZodObject<{
169
+ scope: z.ZodString;
170
+ }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
171
+ scope: z.ZodString;
172
+ }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
173
+ scope: z.ZodString;
174
+ }, z.ZodTypeAny, "passthrough">>;
175
+ export type ScopeSwitchRequest = z.infer<typeof ScopeSwitchRequest>;
176
+ /** A fresh access token bound to the requested scope (`scope` claim = `scope` field below). The refresh
177
+ * grant is untouched — this endpoint never returns a refresh_token. `role` = the caller's effective role
178
+ * inside the new scope (effectiveScopeRole). */
179
+ export declare const ScopeSwitchResponse: z.ZodObject<{
180
+ /** RS256 JWT with the `scope` claim set — verify per api/auth-bridge; VerifiedIdentity.scope carries it. */
181
+ access_token: z.ZodString;
182
+ token_type: z.ZodLiteral<"Bearer">;
183
+ /** Access-token lifetime in seconds (ACCESS_TOKEN_TTL_SECONDS — same mint as api/auth). */
184
+ expires_in: z.ZodNumber;
185
+ scope: z.ZodString;
186
+ role: z.ZodEnum<[UserRole, ...UserRole[]]>;
187
+ }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
188
+ /** RS256 JWT with the `scope` claim set — verify per api/auth-bridge; VerifiedIdentity.scope carries it. */
189
+ access_token: z.ZodString;
190
+ token_type: z.ZodLiteral<"Bearer">;
191
+ /** Access-token lifetime in seconds (ACCESS_TOKEN_TTL_SECONDS — same mint as api/auth). */
192
+ expires_in: z.ZodNumber;
193
+ scope: z.ZodString;
194
+ role: z.ZodEnum<[UserRole, ...UserRole[]]>;
195
+ }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
196
+ /** RS256 JWT with the `scope` claim set — verify per api/auth-bridge; VerifiedIdentity.scope carries it. */
197
+ access_token: z.ZodString;
198
+ token_type: z.ZodLiteral<"Bearer">;
199
+ /** Access-token lifetime in seconds (ACCESS_TOKEN_TTL_SECONDS — same mint as api/auth). */
200
+ expires_in: z.ZodNumber;
201
+ scope: z.ZodString;
202
+ role: z.ZodEnum<[UserRole, ...UserRole[]]>;
203
+ }, z.ZodTypeAny, "passthrough">>;
204
+ export type ScopeSwitchResponse = z.infer<typeof ScopeSwitchResponse>;
205
+ /** Re-exported convenience: the switch endpoint mints through the SAME exit as api/auth (1h JWT). */
206
+ export declare const SCOPE_SWITCH_TOKEN_TTL_SECONDS: number;
207
+ /** Reserved ids ("global") are rejected AT THE SCHEMA — the default scope pre-exists and is not creatable. */
208
+ export declare const ScopeCreateRequest: z.ZodObject<{
209
+ id: z.ZodEffects<z.ZodString, string, string>;
210
+ name: z.ZodString;
211
+ }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
212
+ id: z.ZodEffects<z.ZodString, string, string>;
213
+ name: z.ZodString;
214
+ }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
215
+ id: z.ZodEffects<z.ZodString, string, string>;
216
+ name: z.ZodString;
217
+ }, z.ZodTypeAny, "passthrough">>;
218
+ export type ScopeCreateRequest = z.infer<typeof ScopeCreateRequest>;
219
+ /** The created scope, as stored. */
220
+ export declare const ScopeCreateResponse: z.ZodObject<{
221
+ id: z.ZodString;
222
+ /** Human display name (non-unique, non-secret). */
223
+ name: z.ZodString;
224
+ /** Creation time, epoch MILLISECONDS. The reserved "global" scope reports its provisioning time. */
225
+ createdAt: z.ZodNumber;
226
+ }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
227
+ id: z.ZodString;
228
+ /** Human display name (non-unique, non-secret). */
229
+ name: z.ZodString;
230
+ /** Creation time, epoch MILLISECONDS. The reserved "global" scope reports its provisioning time. */
231
+ createdAt: z.ZodNumber;
232
+ }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
233
+ id: z.ZodString;
234
+ /** Human display name (non-unique, non-secret). */
235
+ name: z.ZodString;
236
+ /** Creation time, epoch MILLISECONDS. The reserved "global" scope reports its provisioning time. */
237
+ createdAt: z.ZodNumber;
238
+ }, z.ZodTypeAny, "passthrough">>;
239
+ export type ScopeCreateResponse = Scope;
240
+ /** PUT body — upsert: sets `principal`'s role in the path scope, creating or replacing the row. */
241
+ export declare const ScopeMemberUpsertRequest: z.ZodObject<{
242
+ principal: z.ZodString;
243
+ role: z.ZodEnum<[UserRole, ...UserRole[]]>;
244
+ }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
245
+ principal: z.ZodString;
246
+ role: z.ZodEnum<[UserRole, ...UserRole[]]>;
247
+ }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
248
+ principal: z.ZodString;
249
+ role: z.ZodEnum<[UserRole, ...UserRole[]]>;
250
+ }, z.ZodTypeAny, "passthrough">>;
251
+ export type ScopeMemberUpsertRequest = z.infer<typeof ScopeMemberUpsertRequest>;
252
+ /** The stored membership row (scopeId echoes the path). */
253
+ export declare const ScopeMemberUpsertResponse: z.ZodObject<{
254
+ scopeId: z.ZodString;
255
+ /** The user identity — same principal string the JWT `sub` / x-agent-principal pipeline uses. */
256
+ principal: z.ZodString;
257
+ role: z.ZodEnum<[UserRole, ...UserRole[]]>;
258
+ }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
259
+ scopeId: z.ZodString;
260
+ /** The user identity — same principal string the JWT `sub` / x-agent-principal pipeline uses. */
261
+ principal: z.ZodString;
262
+ role: z.ZodEnum<[UserRole, ...UserRole[]]>;
263
+ }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
264
+ scopeId: z.ZodString;
265
+ /** The user identity — same principal string the JWT `sub` / x-agent-principal pipeline uses. */
266
+ principal: z.ZodString;
267
+ role: z.ZodEnum<[UserRole, ...UserRole[]]>;
268
+ }, z.ZodTypeAny, "passthrough">>;
269
+ export type ScopeMemberUpsertResponse = ScopeMember;
270
+ /** DELETE body — remove `principal` from the path scope. */
271
+ export declare const ScopeMemberDeleteRequest: z.ZodObject<{
272
+ principal: z.ZodString;
273
+ }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
274
+ principal: z.ZodString;
275
+ }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
276
+ principal: z.ZodString;
277
+ }, z.ZodTypeAny, "passthrough">>;
278
+ export type ScopeMemberDeleteRequest = z.infer<typeof ScopeMemberDeleteRequest>;
279
+ /** Idempotent: `{ok:true}` whether or not the principal was a member (no membership disclosure beyond
280
+ * what the admin caller already sees). */
281
+ export declare const ScopeMemberDeleteResponse: z.ZodObject<{
282
+ ok: z.ZodLiteral<true>;
283
+ }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
284
+ ok: z.ZodLiteral<true>;
285
+ }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
286
+ ok: z.ZodLiteral<true>;
287
+ }, z.ZodTypeAny, "passthrough">>;
288
+ export type ScopeMemberDeleteResponse = z.infer<typeof ScopeMemberDeleteResponse>;
289
+ /** A membership as the decision functions see it (a projection of ScopeMember). */
290
+ export interface ScopeMembershipInput {
291
+ scopeId: string;
292
+ role: UserRole;
293
+ }
294
+ /** What token minting resolved: the scope to stamp into the `scope` claim + the `role` claim value. */
295
+ export interface ResolvedTokenScope {
296
+ scope: string;
297
+ role: string;
298
+ }
299
+ /**
300
+ * THE instance-admin override rule, in one place:
301
+ *
302
+ * **An instance `admin`'s effective role inside ANY scope is always `"admin"`** — regardless of whether
303
+ * they hold a membership there or what that membership says. For everyone else the effective role is
304
+ * exactly their membership role (`membershipRole`), and `undefined` when they have no membership.
305
+ *
306
+ * `instanceRole` is the instance-level users.role string; only the exact value `"admin"` triggers the
307
+ * override (publisher/editor/viewer at instance level grant NO cross-scope power).
308
+ */
309
+ export declare function effectiveScopeRole(membershipRole: UserRole | undefined, instanceRole: string): UserRole | undefined;
310
+ /**
311
+ * THE token-minting scope decision — which scope (and role claim) a fresh access token binds to, given
312
+ * the principal's scope memberships and instance role. Deterministic; the registry (Node/Next) imports
313
+ * and calls this at every mint (device-flow grant AND refresh). NORMATIVE RULES (verbatim, 任务书拍定):
314
+ *
315
+ * 1. **唯一成员 scope** — exactly one membership → bind that scope, role = that membership's effective
316
+ * role (effectiveScopeRole: instance admin ⇒ "admin").
317
+ * 2. **多个含 global** — multiple memberships, one of them "global" → bind "global", role = the global
318
+ * membership's effective role.
319
+ * 3. **多个不含 global** — multiple memberships, none "global" → bind the scope whose id is FIRST in
320
+ * lexicographic (UTF-16 code-unit) order — deterministic, no wall-clock/insertion-order dependence;
321
+ * role = that membership's effective role.
322
+ * 4. **零成员** — no memberships → `{scope: "global", role: instanceRole}` (the instance role IS the
323
+ * role claim, exactly as pre-scope tokens behaved — full backward compatibility).
324
+ *
325
+ * Duplicate scopeIds in the input: the entry that sorts first (by scopeId, then by role string) wins —
326
+ * callers should not pass duplicates, but the function stays deterministic if they do.
327
+ */
328
+ export declare function resolveTokenScope(memberships: readonly ScopeMembershipInput[], instanceRole: string): ResolvedTokenScope;
329
+ /**
330
+ * THE scope-switch authorization decision (POST /api/v1/auth/scope): allowed ⇔ the caller is a MEMBER of
331
+ * `targetScope` (any role) OR an instance `admin` (who may enter any scope, including ones they are not
332
+ * a member of — their effective role there is "admin" per effectiveScopeRole). Everyone else →
333
+ * `access_denied`. Note: "global" gets NO special-case here — a multi-scope user without a global
334
+ * membership cannot switch to global (their default mint never lands there either, rule 3 above).
335
+ */
336
+ export declare function canSwitchScope(memberships: readonly ScopeMembershipInput[], instanceRole: string, targetScope: string): boolean;
337
+ /**
338
+ * Read the active scope off verified JWT claims: a non-empty string `scope` claim, else GLOBAL_SCOPE.
339
+ * This is the ONE place the "missing claim = global" backward-compat rule lives — workers and the
340
+ * registry both call this instead of defaulting ad hoc.
341
+ */
342
+ export declare function tokenScopeOf(claims: {
343
+ scope?: unknown;
344
+ }): string;
@@ -0,0 +1,222 @@
1
+ /**
2
+ * `api/scopes` — the MULTI-TENANT SCOPE wire contract for the registry (/api/v1/scopes* +
3
+ * /api/v1/auth/scope). Frozen here so sema-registry (the implementation), the orchestrator, workers and
4
+ * the sema shell import ONE source of truth — same "契约先行" discipline as `api/auth`.
5
+ *
6
+ * THE SCOPE MODEL (normative — the registry implements EXACTLY this):
7
+ *
8
+ * - A **scope** is a tenant boundary: `{id, name, createdAt}`. `id` is a slug (SCOPE_ID_REGEX).
9
+ * The reserved id `"global"` (GLOBAL_SCOPE) is the DEFAULT scope: it always exists, is never
10
+ * created via the API (ScopeCreateRequest rejects it) and is never deleted.
11
+ * - **Membership** is per-scope: `{scopeId, principal, role}` where role is the existing 4-level
12
+ * RBAC ladder (viewer < editor < publisher < admin). The SAME principal may be `editor` in scope A
13
+ * and `viewer` in scope B.
14
+ * - The **instance-level** `users.role` is unchanged and orthogonal: an instance `admin` administers
15
+ * ALL scopes — inside any scope their effective role is always `"admin"` (see effectiveScopeRole).
16
+ * - The **access JWT** gains an OPTIONAL `scope` claim (SCOPE_CLAIM) binding the token to its active
17
+ * scope. A token WITHOUT the claim means `"global"` (tokenScopeOf) — full backward compatibility:
18
+ * every pre-scope token keeps working, scoped to global. The refresh token is NOT scope-bound;
19
+ * refresh re-mints per resolveTokenScope, and the client re-switches if it wants another scope.
20
+ *
21
+ * ENDPOINTS (paths in SCOPES_V1_PATHS / scopeMembersPath):
22
+ *
23
+ * GET /api/v1/scopes → ScopesListResponse {scopes:[{id,name,role}]} — the CALLER's
24
+ * scopes with their per-scope effective role (instance admin sees ALL scopes, role "admin").
25
+ * POST /api/v1/auth/scope → switch active scope: ScopeSwitchRequest {scope} →
26
+ * ScopeSwitchResponse {access_token, token_type:"Bearer", expires_in, scope, role}; errors are
27
+ * OAuth-style OAuthErrorResponse (`invalid_request` = malformed / unknown scope shape,
28
+ * `access_denied` = caller is not a member and not an instance admin — canSwitchScope).
29
+ * Auth: a valid access token (any scope). Mints a NEW access token bound to `scope`; the
30
+ * caller's refresh grant is untouched.
31
+ * POST /api/v1/scopes → create a scope (instance admin only): ScopeCreateRequest
32
+ * {id,name} → ScopeCreateResponse (the created Scope).
33
+ * PUT /api/v1/scopes/{id}/members → upsert a member (instance admin only): ScopeMemberUpsertRequest
34
+ * {principal, role} → ScopeMemberUpsertResponse (the stored membership row).
35
+ * DELETE /api/v1/scopes/{id}/members → remove a member (instance admin only): ScopeMemberDeleteRequest
36
+ * {principal} → ScopeMemberDeleteResponse {ok:true} (idempotent — removing a non-member is 200).
37
+ *
38
+ * WIRE CASING: /api/v1/auth/scope is part of the OAuth face — snake_case on the wire (access_token,
39
+ * expires_in), matching `api/auth`. The /api/v1/scopes management face uses this package's camelCase
40
+ * (createdAt, scopeId) — it is a registry management API, not an OAuth endpoint.
41
+ *
42
+ * Pure zod contract + pure decision functions — zero IO, browser-safe. The decision functions
43
+ * (resolveTokenScope / canSwitchScope / effectiveScopeRole) are THE semantics: the registry
44
+ * implementation imports and calls these — it must not re-implement them.
45
+ */
46
+ import { z } from "zod";
47
+ import { USER_ROLES } from "../config-fns.js";
48
+ import { ACCESS_TOKEN_TTL_SECONDS } from "./auth.js";
49
+ // ── constants ────────────────────────────────────────────────────────────────────────────────────────────
50
+ /** The reserved default scope. Always exists; every token without a `scope` claim lives here. */
51
+ export const GLOBAL_SCOPE = "global";
52
+ /** Scope ids that can never be created (or deleted) through the API. */
53
+ export const RESERVED_SCOPE_IDS = [GLOBAL_SCOPE];
54
+ /** Slug shape for scope ids: lowercase alphanumeric + inner dashes, 1–64 chars, no leading/trailing dash. */
55
+ export const SCOPE_ID_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
56
+ /** The scope endpoints, relative to the registry origin. `members` needs the scope id — use scopeMembersPath. */
57
+ import { SCOPES_V1_PATHS } from "./wire.js";
58
+ export { SCOPES_V1_PATHS, SCOPE_CLAIM } from "./wire.js"; // moved to the zero-dep wire subset (re-export = 字节兼容)
59
+ /** `/api/v1/scopes/{id}/members` for PUT (upsert) and DELETE (remove). */
60
+ export function scopeMembersPath(scopeId) {
61
+ return `${SCOPES_V1_PATHS.scopes}/${encodeURIComponent(scopeId)}/members`;
62
+ }
63
+ /** True for ids the API refuses to create/delete ("global"). */
64
+ export function isReservedScopeId(id) {
65
+ return RESERVED_SCOPE_IDS.includes(id);
66
+ }
67
+ // ── core shapes ──────────────────────────────────────────────────────────────────────────────────────────
68
+ /** A scope id on the wire — slug-validated. (The reserved "global" IS a valid ScopeId: it exists as an
69
+ * entity everywhere except the create endpoint.) */
70
+ export const ScopeId = z.string().regex(SCOPE_ID_REGEX, "scope id must be a slug: [a-z0-9-], 1-64 chars, no edge dash");
71
+ /** The per-scope role ladder = the existing instance RBAC ladder (viewer < editor < publisher < admin). */
72
+ export const ScopeRole = z.enum(USER_ROLES);
73
+ /** The scope entity. */
74
+ export const Scope = z
75
+ .object({
76
+ id: ScopeId,
77
+ /** Human display name (non-unique, non-secret). */
78
+ name: z.string().min(1).max(200),
79
+ /** Creation time, epoch MILLISECONDS. The reserved "global" scope reports its provisioning time. */
80
+ createdAt: z.number(),
81
+ })
82
+ .passthrough();
83
+ /** One membership row: `principal` holds `role` inside `scopeId`. Per-scope — the same principal may
84
+ * hold different roles in different scopes. */
85
+ export const ScopeMember = z
86
+ .object({
87
+ scopeId: ScopeId,
88
+ /** The user identity — same principal string the JWT `sub` / x-agent-principal pipeline uses. */
89
+ principal: z.string().min(1),
90
+ role: ScopeRole,
91
+ })
92
+ .passthrough();
93
+ // ── GET /api/v1/scopes ───────────────────────────────────────────────────────────────────────────────────
94
+ /** One list entry: a scope the caller can act in, with the caller's EFFECTIVE role there
95
+ * (effectiveScopeRole — instance admins list every scope with role "admin"). */
96
+ export const ScopeListEntry = z
97
+ .object({
98
+ id: ScopeId,
99
+ name: z.string().min(1),
100
+ role: ScopeRole,
101
+ })
102
+ .passthrough();
103
+ export const ScopesListResponse = z.object({ scopes: z.array(ScopeListEntry) }).passthrough();
104
+ // ── POST /api/v1/auth/scope (switch active scope — OAuth face, snake_case) ──────────────────────────────
105
+ /** Request: the target scope. Errors: `invalid_request` (malformed), `access_denied` (not a member and
106
+ * not an instance admin — canSwitchScope false). */
107
+ export const ScopeSwitchRequest = z
108
+ .object({
109
+ scope: ScopeId,
110
+ })
111
+ .passthrough();
112
+ /** A fresh access token bound to the requested scope (`scope` claim = `scope` field below). The refresh
113
+ * grant is untouched — this endpoint never returns a refresh_token. `role` = the caller's effective role
114
+ * inside the new scope (effectiveScopeRole). */
115
+ export const ScopeSwitchResponse = z
116
+ .object({
117
+ /** RS256 JWT with the `scope` claim set — verify per api/auth-bridge; VerifiedIdentity.scope carries it. */
118
+ access_token: z.string().min(1),
119
+ token_type: z.literal("Bearer"),
120
+ /** Access-token lifetime in seconds (ACCESS_TOKEN_TTL_SECONDS — same mint as api/auth). */
121
+ expires_in: z.number().int().positive(),
122
+ scope: ScopeId,
123
+ role: ScopeRole,
124
+ })
125
+ .passthrough();
126
+ /** Re-exported convenience: the switch endpoint mints through the SAME exit as api/auth (1h JWT). */
127
+ export const SCOPE_SWITCH_TOKEN_TTL_SECONDS = ACCESS_TOKEN_TTL_SECONDS;
128
+ // ── POST /api/v1/scopes (create — instance admin only) ──────────────────────────────────────────────────
129
+ /** Reserved ids ("global") are rejected AT THE SCHEMA — the default scope pre-exists and is not creatable. */
130
+ export const ScopeCreateRequest = z
131
+ .object({
132
+ id: ScopeId.refine((id) => !isReservedScopeId(id), { message: `"${GLOBAL_SCOPE}" is reserved` }),
133
+ name: z.string().min(1).max(200),
134
+ })
135
+ .passthrough();
136
+ /** The created scope, as stored. */
137
+ export const ScopeCreateResponse = Scope;
138
+ // ── PUT/DELETE /api/v1/scopes/{id}/members (membership writes — instance admin only) ────────────────────
139
+ /** PUT body — upsert: sets `principal`'s role in the path scope, creating or replacing the row. */
140
+ export const ScopeMemberUpsertRequest = z
141
+ .object({
142
+ principal: z.string().min(1),
143
+ role: ScopeRole,
144
+ })
145
+ .passthrough();
146
+ /** The stored membership row (scopeId echoes the path). */
147
+ export const ScopeMemberUpsertResponse = ScopeMember;
148
+ /** DELETE body — remove `principal` from the path scope. */
149
+ export const ScopeMemberDeleteRequest = z
150
+ .object({
151
+ principal: z.string().min(1),
152
+ })
153
+ .passthrough();
154
+ /** Idempotent: `{ok:true}` whether or not the principal was a member (no membership disclosure beyond
155
+ * what the admin caller already sees). */
156
+ export const ScopeMemberDeleteResponse = z.object({ ok: z.literal(true) }).passthrough();
157
+ /**
158
+ * THE instance-admin override rule, in one place:
159
+ *
160
+ * **An instance `admin`'s effective role inside ANY scope is always `"admin"`** — regardless of whether
161
+ * they hold a membership there or what that membership says. For everyone else the effective role is
162
+ * exactly their membership role (`membershipRole`), and `undefined` when they have no membership.
163
+ *
164
+ * `instanceRole` is the instance-level users.role string; only the exact value `"admin"` triggers the
165
+ * override (publisher/editor/viewer at instance level grant NO cross-scope power).
166
+ */
167
+ export function effectiveScopeRole(membershipRole, instanceRole) {
168
+ if (instanceRole === "admin")
169
+ return "admin";
170
+ return membershipRole;
171
+ }
172
+ /**
173
+ * THE token-minting scope decision — which scope (and role claim) a fresh access token binds to, given
174
+ * the principal's scope memberships and instance role. Deterministic; the registry (Node/Next) imports
175
+ * and calls this at every mint (device-flow grant AND refresh). NORMATIVE RULES (verbatim, 任务书拍定):
176
+ *
177
+ * 1. **唯一成员 scope** — exactly one membership → bind that scope, role = that membership's effective
178
+ * role (effectiveScopeRole: instance admin ⇒ "admin").
179
+ * 2. **多个含 global** — multiple memberships, one of them "global" → bind "global", role = the global
180
+ * membership's effective role.
181
+ * 3. **多个不含 global** — multiple memberships, none "global" → bind the scope whose id is FIRST in
182
+ * lexicographic (UTF-16 code-unit) order — deterministic, no wall-clock/insertion-order dependence;
183
+ * role = that membership's effective role.
184
+ * 4. **零成员** — no memberships → `{scope: "global", role: instanceRole}` (the instance role IS the
185
+ * role claim, exactly as pre-scope tokens behaved — full backward compatibility).
186
+ *
187
+ * Duplicate scopeIds in the input: the entry that sorts first (by scopeId, then by role string) wins —
188
+ * callers should not pass duplicates, but the function stays deterministic if they do.
189
+ */
190
+ export function resolveTokenScope(memberships, instanceRole) {
191
+ if (memberships.length === 0) {
192
+ return { scope: GLOBAL_SCOPE, role: instanceRole };
193
+ }
194
+ const sorted = [...memberships].sort((a, b) => a.scopeId < b.scopeId ? -1 : a.scopeId > b.scopeId ? 1 : a.role < b.role ? -1 : a.role > b.role ? 1 : 0);
195
+ if (sorted.length === 1) {
196
+ const only = sorted[0];
197
+ return { scope: only.scopeId, role: effectiveScopeRole(only.role, instanceRole) ?? only.role };
198
+ }
199
+ const global = sorted.find((m) => m.scopeId === GLOBAL_SCOPE);
200
+ const chosen = global ?? sorted[0];
201
+ return { scope: chosen.scopeId, role: effectiveScopeRole(chosen.role, instanceRole) ?? chosen.role };
202
+ }
203
+ /**
204
+ * THE scope-switch authorization decision (POST /api/v1/auth/scope): allowed ⇔ the caller is a MEMBER of
205
+ * `targetScope` (any role) OR an instance `admin` (who may enter any scope, including ones they are not
206
+ * a member of — their effective role there is "admin" per effectiveScopeRole). Everyone else →
207
+ * `access_denied`. Note: "global" gets NO special-case here — a multi-scope user without a global
208
+ * membership cannot switch to global (their default mint never lands there either, rule 3 above).
209
+ */
210
+ export function canSwitchScope(memberships, instanceRole, targetScope) {
211
+ if (instanceRole === "admin")
212
+ return true;
213
+ return memberships.some((m) => m.scopeId === targetScope);
214
+ }
215
+ /**
216
+ * Read the active scope off verified JWT claims: a non-empty string `scope` claim, else GLOBAL_SCOPE.
217
+ * This is the ONE place the "missing claim = global" backward-compat rule lives — workers and the
218
+ * registry both call this instead of defaulting ad hoc.
219
+ */
220
+ export function tokenScopeOf(claims) {
221
+ return typeof claims.scope === "string" && claims.scope.length > 0 ? claims.scope : GLOBAL_SCOPE;
222
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * `api/wire` — the ZERO-DEPENDENCY wire subset of the auth/scopes contracts (server AI 接手首车,
3
+ * 2026-07-27,跨仓 candidate 落地):endpoint paths, protocol constants and PURE helper functions,
4
+ * with **no zod import** — so a zero-runtime-dependency client (e.g. `@sema-agent/sdk/registry`) can
5
+ * import these at RUNTIME instead of value-copying them behind a same-source anchor test.
6
+ *
7
+ * ⚠️ 拆分纪律:这里只放**纯**件(常量/纯函数/字面量类型)。zod schema 留在 `api/auth.ts` /
8
+ * `api/scopes.ts`(它们 re-export 本文件 ⇒ 既有导入面字节兼容,谁都不用改)。给本文件加东西前
9
+ * 自问:它 import 了任何东西吗?——答案必须是 no(test/wire-purity 打包门盯着)。
10
+ */
11
+ /** The five auth endpoints, relative to the registry origin. */
12
+ export declare const AUTH_V1_PATHS: {
13
+ readonly deviceCode: "/api/v1/auth/device/code";
14
+ readonly deviceToken: "/api/v1/auth/device/token";
15
+ readonly deviceApprove: "/api/v1/auth/device/approve";
16
+ readonly tokenRefresh: "/api/v1/auth/token/refresh";
17
+ readonly logout: "/api/v1/auth/logout";
18
+ };
19
+ /** The RFC 8628 grant_type the device token endpoint requires. */
20
+ export declare const DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
21
+ /** Minimum seconds between token-endpoint polls (RFC 8628 `interval`); polling faster → `slow_down`. */
22
+ export declare const DEVICE_POLL_INTERVAL_SECONDS = 5;
23
+ /** Device-code handshake lifetime (seconds) — the user has 15 minutes to open /activate and approve. */
24
+ export declare const DEVICE_CODE_TTL_SECONDS: number;
25
+ /** Access-token (RS256 JWT) lifetime in seconds — 1h, re-minted via refresh. */
26
+ export declare const ACCESS_TOKEN_TTL_SECONDS: number;
27
+ /** Refresh-grant lifetime (seconds) — 30d SLIDING: every rotation opens a fresh 30d window. */
28
+ export declare const REFRESH_TOKEN_TTL_SECONDS: number;
29
+ /** Every error code the /api/v1/auth/* endpoints answer (HTTP 400 unless noted). */
30
+ export declare const OAUTH_ERROR_CODES: readonly ["authorization_pending", "slow_down", "expired_token", "access_denied", "invalid_request", "unsupported_grant_type", "invalid_grant", "server_error"];
31
+ export type OAuthErrorCode = (typeof OAUTH_ERROR_CODES)[number];
32
+ /** Handshake states a consent-screen lookup can see. (`approved` rows vanish once exchanged/consumed.)
33
+ * `denied` is contract-reserved for an explicit deny button (pairs with the `access_denied` poll error);
34
+ * the shipped M1 store carries pending|approved only — adding deny later is implementation, not schema. */
35
+ export declare const DEVICE_AUTH_STATUSES: readonly ["pending", "approved", "denied"];
36
+ export type DeviceAuthStatus = (typeof DEVICE_AUTH_STATUSES)[number];
37
+ /**
38
+ * Canonicalize a human-typed user code to the wire shape "XXXX-XXXX": uppercase, dashes/spaces optional.
39
+ * null = not a plausible code (wrong length/charset after stripping separators). Same normalization the
40
+ * server applies — run it client-side for instant feedback.
41
+ */
42
+ export declare function normalizeUserCode(input: unknown): string | null;
43
+ /**
44
+ * The RFC 8628 §3.5 poll-loop step: what interval to poll with next, and whether to keep going.
45
+ * - authorization_pending → keep polling at the current interval
46
+ * - slow_down → keep polling, interval + 5s (the RFC-mandated increment)
47
+ * - anything else → stop (expired_token/access_denied = terminal; invalid_* = client bug)
48
+ */
49
+ export declare function nextPollInterval(currentIntervalSeconds: number, error: OAuthErrorCode): {
50
+ continue: boolean;
51
+ intervalSeconds: number;
52
+ };
53
+ export declare const SCOPES_V1_PATHS: {
54
+ /** GET (list) + POST (create). */
55
+ readonly scopes: "/api/v1/scopes";
56
+ /** POST — mint a new access token bound to another scope. */
57
+ readonly authScope: "/api/v1/auth/scope";
58
+ };
59
+ /** The JWT claim carrying the token's active scope. OPTIONAL — absent means GLOBAL_SCOPE (tokenScopeOf). */
60
+ export declare const SCOPE_CLAIM = "scope";