@spacefast/common 0.0.12 → 0.0.13

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 (113) hide show
  1. package/dist/contracts/access.d.ts +386 -1201
  2. package/dist/contracts/access.js +240 -1057
  3. package/dist/contracts/activity.d.ts +1 -1
  4. package/dist/contracts/activity.js +6 -14
  5. package/dist/contracts/api-keys.d.ts +16 -0
  6. package/dist/contracts/api-keys.js +2 -0
  7. package/dist/contracts/archives.d.ts +1 -2
  8. package/dist/contracts/archives.js +4 -6
  9. package/dist/contracts/comments.d.ts +58 -85
  10. package/dist/contracts/comments.js +86 -67
  11. package/dist/contracts/countries.js +2 -7
  12. package/dist/contracts/data-source-routes.d.ts +20 -0
  13. package/dist/contracts/data-source-routes.js +15 -0
  14. package/dist/contracts/device-auth.d.ts +7 -3
  15. package/dist/contracts/enums.d.ts +2 -4
  16. package/dist/contracts/enums.js +3 -8
  17. package/dist/contracts/error-code-meta.d.ts +17 -20
  18. package/dist/contracts/error-code-meta.js +8 -5
  19. package/dist/contracts/error-codes.d.ts +1 -1
  20. package/dist/contracts/error-codes.js +4 -5
  21. package/dist/contracts/events.d.ts +13 -13
  22. package/dist/contracts/execution.d.ts +212 -0
  23. package/dist/contracts/execution.js +288 -0
  24. package/dist/contracts/feature-lifecycle.d.ts +21 -167
  25. package/dist/contracts/feature-lifecycle.js +27 -173
  26. package/dist/contracts/features.d.ts +13 -115
  27. package/dist/contracts/features.js +1 -31
  28. package/dist/contracts/functions.d.ts +729 -0
  29. package/dist/contracts/functions.js +485 -0
  30. package/dist/contracts/git.d.ts +1 -1
  31. package/dist/contracts/grants.d.ts +1158 -0
  32. package/dist/contracts/grants.js +696 -0
  33. package/dist/contracts/ids.d.ts +5 -3
  34. package/dist/contracts/ids.js +5 -3
  35. package/dist/contracts/mcp.d.ts +6 -6
  36. package/dist/contracts/oauth-resources.d.ts +5 -5
  37. package/dist/contracts/oauth-resources.js +1 -0
  38. package/dist/contracts/operations.d.ts +2 -2
  39. package/dist/contracts/pages.d.ts +6 -14
  40. package/dist/contracts/pages.js +6 -23
  41. package/dist/contracts/plan-policy.d.ts +1 -8
  42. package/dist/contracts/plan-policy.js +1 -4
  43. package/dist/contracts/platform.d.ts +78 -30
  44. package/dist/contracts/platform.js +59 -12
  45. package/dist/contracts/push-new.d.ts +26 -2
  46. package/dist/contracts/push-new.js +8 -0
  47. package/dist/contracts/quotas.d.ts +0 -1
  48. package/dist/contracts/quotas.js +0 -5
  49. package/dist/contracts/repository-connections.d.ts +1 -1
  50. package/dist/contracts/resources.d.ts +10 -66
  51. package/dist/contracts/resources.js +4 -10
  52. package/dist/contracts/runtime-api.d.ts +558 -265
  53. package/dist/contracts/runtime-api.js +178 -91
  54. package/dist/contracts/runtime-app.d.ts +138 -0
  55. package/dist/contracts/runtime-app.js +21 -0
  56. package/dist/contracts/sf-config-v1.d.ts +5 -0
  57. package/dist/contracts/sites.d.ts +5 -0
  58. package/dist/contracts/sites.js +12 -1
  59. package/dist/contracts/space-config.d.ts +46 -116
  60. package/dist/contracts/space-config.js +320 -24
  61. package/dist/contracts/spaces.d.ts +253 -158
  62. package/dist/contracts/spaces.js +68 -54
  63. package/dist/contracts/superadmin-emails.d.ts +2 -2
  64. package/dist/contracts/superadmin-spaces.d.ts +15 -130
  65. package/dist/contracts/superadmin-spaces.js +7 -0
  66. package/dist/contracts/tags.d.ts +23 -23
  67. package/dist/contracts/teams.d.ts +9 -9
  68. package/dist/contracts/transfers.d.ts +0 -1
  69. package/dist/contracts/zero.d.ts +195 -273
  70. package/dist/contracts/zero.js +78 -206
  71. package/dist/dashboard-paths/index.d.ts +2 -2
  72. package/dist/dashboard-paths/index.js +1 -0
  73. package/dist/docs/agent-prose.js +10 -8
  74. package/dist/docs/agent-setup.d.ts +2 -2
  75. package/dist/docs/agent-setup.js +11 -9
  76. package/dist/docs/agent-solutions.js +1 -1
  77. package/dist/docs/catalog.d.ts +28 -28
  78. package/dist/docs/catalog.js +29 -29
  79. package/dist/docs/error-docs.js +23 -27
  80. package/dist/utils/asset-fingerprint.d.ts +11 -0
  81. package/dist/utils/asset-fingerprint.js +46 -0
  82. package/dist/utils/auth-redirect.d.ts +1 -3
  83. package/dist/utils/auth-redirect.js +1 -21
  84. package/dist/utils/build-settings.d.ts +2 -0
  85. package/dist/utils/build-settings.js +61 -9
  86. package/dist/utils/credential-policy.d.ts +1 -1
  87. package/dist/utils/credential-policy.js +4 -0
  88. package/dist/utils/egress-policy.fixtures.json +1 -1
  89. package/dist/utils/grants.d.ts +53 -0
  90. package/dist/utils/grants.js +160 -0
  91. package/dist/utils/local-space-state.js +1 -0
  92. package/dist/utils/pages.d.ts +11 -5
  93. package/dist/utils/pages.js +19 -32
  94. package/dist/utils/publish-policy.d.ts +34 -4
  95. package/dist/utils/publish-policy.js +132 -61
  96. package/dist/utils/query-keys.d.ts +1 -6
  97. package/dist/utils/query-keys.js +1 -6
  98. package/dist/utils/space-config.d.ts +5 -1
  99. package/dist/utils/space-config.js +35 -0
  100. package/dist/utils/static-runtime-policy.d.ts +5 -0
  101. package/dist/utils/static-runtime-policy.js +54 -10
  102. package/dist/utils/upload-session.d.ts +3 -0
  103. package/dist/utils/upload-session.js +31 -0
  104. package/package.json +2 -1
  105. package/dist/utils/access-grant.d.ts +0 -14
  106. package/dist/utils/access-grant.js +0 -88
  107. package/dist/utils/access-match.d.ts +0 -46
  108. package/dist/utils/access-match.fixtures.json +0 -513
  109. package/dist/utils/access-match.js +0 -312
  110. package/dist/utils/access-verdict.fixtures.json +0 -421
  111. package/dist/utils/sf-config-v1.d.ts +0 -2
  112. package/dist/utils/sf-config-v1.fixtures.json +0 -9
  113. package/dist/utils/sf-config-v1.js +0 -551
@@ -1,1091 +1,274 @@
1
1
  import { z } from "zod";
2
- import { ISO_COUNTRY_CODES } from "./countries.js";
3
- // Unified access contracts (internal-docs/access-plan.html — the only access
4
- // document).
5
- //
6
- // The one access & policy lane. The headline simplification: **firewall ⊂
7
- // access** — there is exactly one Rule schema, and an anonymous firewall rule
8
- // is simply a Rule whose `auth` is absent. There is exactly ONE satisfaction
9
- // test: token grants ∩ requiredGrants (X-32); `auth.acquire` only configures
10
- // how the challenge page lets a visitor obtain a token. The runtime verifies
11
- // fully offline; the cloud resolves identity and mints signed tokens (plus
12
- // exactly one non-cloud minter: the runtime's space-local `pw:` mint). Field
13
- // names are the spec contract — do not rename.
2
+ import { grantConstraintsSchema, grantResourceSetSchema, grantTargetSelectorSchema, } from "./grants.js";
14
3
  // ---------------------------------------------------------------------------
15
- // 1. Grants
4
+ // Canonical sharing model
16
5
  // ---------------------------------------------------------------------------
17
- // A grant is a string in a verified token (or a `requiredGrants` entry on a
18
- // rule). Identity is a string set: the runtime is a verifier and
19
- // set-intersector a visitor passes when `grants ∩ requiredGrants ≠ ∅`.
20
- //
21
- // Grants are globs. One namespace per kind, with `*` wildcards permitted in
22
- // `requiredGrants` so a rule can name a class of identities:
23
- //
24
- // email:alice@acme.com a single verified email
25
- // email:*@acme.com any verified email on a domain (glob — this IS
26
- // the "anyone at a domain" audience; there is no
27
- // separate email-domain namespace)
28
- // team:team_123:member top-level team membership at a role
29
- // sub-team:sub_123:member enterprise group membership inside a team
30
- // user:<handle> a specific platform person
31
- // space:{spaceId}:{level} a leveled space grant (SPACE_LEVELS)
32
- // link:lnk_x an active share link (structural revocation)
33
- // invite:sin_x an accepted invite (structural revocation)
34
- // svc:stk_x a service token (CI, agents; structural)
35
- // sso:acn_x an OIDC connection identity
36
- // ext:{connectionId}:<tag> an owner-defined external (BYO issuer) grant
37
- // pw:{ruleId} a satisfied password wall (runtime-local mint —
38
- // the ONLY namespace the space-local key signs)
39
- //
40
- // Namespaces exist iff a minter exists. Tokens signed by an external (BYO) key
41
- // may only carry grants namespaced to that key (see
42
- // `accessKeySchema.grantNamespaces`); everything else is dropped before
43
- // intersection.
44
- export const grantSchema = z.string().min(1).max(255);
45
- // Known grant namespace prefixes — final vocabulary (access-plan §1).
46
- export const GRANT_NAMESPACES = [
47
- "email:",
48
- "team:",
49
- "sub-team:",
50
- "user:",
51
- "space:",
52
- "link:",
53
- "invite:",
54
- "svc:",
55
- "sso:",
56
- "ext:",
57
- "pw:",
58
- ];
59
- const GRANT_PATTERN = /^(email|team|sub-team|user|space|link|invite|svc|sso|ext|pw):.+$/;
60
- // Validator for grant globs like "email:*@acme.com", "team:team_123:member",
61
- // "sub-team:sub_123:member", "user:<h>", "svc:…", "ext:{conn}:…". Returns true when the grant string is a
62
- // well-formed `namespace:value`. This is advisory (the runtime intersects
63
- // opaque strings); use it for authoring-surface validation, not enforcement.
64
- export function isWellFormedGrant(grant) {
65
- return GRANT_PATTERN.test(grant);
6
+ export const SPACE_ACCESS_PRESETS = ["private", "team", "public"];
7
+ export const ACCESS_HANDOFF_MEDIA_TYPE = "application/vnd.spacefast.access-handoff+json";
8
+ export const spaceAccessPresetSchema = z.enum(SPACE_ACCESS_PRESETS);
9
+ export const ACCESS_ROLES = ["viewer", "commenter", "editor", "manager"];
10
+ export const accessRoleSchema = z.enum(ACCESS_ROLES);
11
+ export function accessRoleRank(role) {
12
+ return ACCESS_ROLES.indexOf(role);
66
13
  }
67
- // A `requiredGrants` entry may contain `*` wildcards; a minted token grant must
68
- // be concrete (no `*`). Both share `grantSchema`; this narrows the authoring
69
- // case.
70
- export const requiredGrantSchema = grantSchema.refine(isWellFormedGrant, {
71
- message: "grant must be namespaced, e.g. email:*@acme.com, team:team_123:member, sub-team:sub_123:member, user:<h>, svc:…, ext:{conn}:…",
72
- });
73
- // The ONE space-level vocabulary (access-plan X-38): consumed by
74
- // `space:{id}:{level}` grants, Cast's capability table, invitee levels, and the
75
- // future editor role. No parallel level enum may exist anywhere. v1 surfaces
76
- // mint only `viewer` and `commenter`.
77
- export const SPACE_LEVELS = ["viewer", "commenter", "editor", "admin"];
78
- export const spaceLevelSchema = z.enum(SPACE_LEVELS);
79
- // ---------------------------------------------------------------------------
80
- // 2. Issuer registry
81
- // ---------------------------------------------------------------------------
82
- // A registered signing key. SSO + BYO-issuer keys are registered once at
83
- // team/space level; rules reference grants, and a token's grants are dropped
84
- // unless namespaced to one of `grantNamespaces`. Service tokens are just tokens
85
- // carrying `svc:` grants signed by a platform key.
86
- export const accessKeySchema = z.object({
87
- kid: z.string().min(1).describe("Key id; matches the token header `kid`."),
88
- alg: z.literal("EdDSA"),
89
- publicKey: z.string().min(1).describe("base64url-encoded raw Ed25519 public key."),
90
- // Grants in a token signed by this key are DROPPED unless prefixed by one of
91
- // these. Platform keys list the platform prefixes
92
- // (["team:","sub-team:","user:","email:","space:","link:","svc:","sso:"]);
93
- // external keys list exactly ["ext:{connectionId}:"], bounding a leaked key's
94
- // blast radius to the rules the owner pointed at it.
95
- grantNamespaces: z.array(z.string().min(1)).min(1),
96
- });
97
- // ---------------------------------------------------------------------------
98
- // 2b. Identity connections (access-plan §4.4/§6.2/§6.3, X-18/X-19)
99
- // ---------------------------------------------------------------------------
100
- // A team-level identity connection (`acn_` row): either a company OIDC
101
- // relying-party config (§6.2 — the authorize endpoint runs the code flow and
102
- // mints the visitor token; no Spacefast account is created) or an external
103
- // BYO issuer (§6.3 — the shop holds the private key and mints its own
104
- // Ed25519 tokens; Spacefast is not in the login path).
105
- export const ACCESS_CONNECTION_TYPES = ["oidc", "external"];
106
- export const accessConnectionTypeSchema = z.enum(ACCESS_CONNECTION_TYPES);
107
- // Session TTL defaults (§4.4): 8h for company SSO (re-auth is a transparent
108
- // bounce while the IdP session lives), 30min for external issuers (the short
109
- // TTL bounds entitlement drift — a cancelled subscriber survives at most one
110
- // TTL).
111
- export const OIDC_CONNECTION_DEFAULT_TTL_SECONDS = 8 * 60 * 60; // 28800
112
- export const EXTERNAL_CONNECTION_DEFAULT_TTL_SECONDS = 30 * 60; // 1800
113
- // Hard ceiling on an EXTERNAL-issued token's exp (§6.3: exp ≤ 30min). The
114
- // connection's sessionTtlSeconds may be shorter, never longer.
115
- export const EXTERNAL_TOKEN_MAX_TTL_SECONDS = 30 * 60;
116
- // The grant a company-SSO visitor carries (§6.2): `sso:{connectionId}`.
117
- export function ssoGrant(connectionId) {
118
- return `sso:${connectionId}`;
119
- }
120
- // The grant namespace an external connection's keys are bound to (§6.3):
121
- // tokens signed by a shop key are dropped unless prefixed `ext:{acn}:` — a
122
- // leaked shop key can forge exactly the rules the owner pointed at it, never
123
- // team:/user: grants (corpus-pinned).
124
- export function externalGrantNamespace(connectionId) {
125
- return `ext:${connectionId}:`;
126
- }
127
- // An owner-defined external grant (§6.3): `ext:{acn}:{tag}`, e.g.
128
- // `ext:acn_x:wc-subscriber`.
129
- export function externalGrant(connectionId, tag) {
130
- return `ext:${connectionId}:${tag}`;
14
+ export function maxAccessRole(left, right) {
15
+ if (left === null)
16
+ return right;
17
+ if (right === null)
18
+ return left;
19
+ return accessRoleRank(left) >= accessRoleRank(right) ? left : right;
131
20
  }
132
- // STORED form of a connection login acquire (`loginAcquireSchema.url`): the
133
- // compiler stores the symbolic `connection:{acn}` reference and the resolver
134
- // rewrites it to the live URL on every policy resolution (the OIDC leg URL or
135
- // the shop's authorizeUrl with the CURRENT space/sv baked in). The serving
136
- // runtime adds the actual request host and same-origin return path. Nothing
137
- // stored ever carries a stale sessionVersion; a revoked connection's acquire
138
- // resolves to nothing (the login block disappears — fail closed).
139
- export function connectionAcquireRef(connectionId) {
140
- return `connection:${connectionId}`;
141
- }
142
- export function parseConnectionAcquireRef(url) {
143
- if (!url.startsWith("connection:")) {
144
- return null;
21
+ function hasUnsafeScopeCharacter(value) {
22
+ for (const character of value) {
23
+ const codePoint = character.codePointAt(0);
24
+ if (codePoint === undefined ||
25
+ codePoint <= 0x1f ||
26
+ codePoint === 0x7f ||
27
+ character === "\\" ||
28
+ character === "?" ||
29
+ character === "#") {
30
+ return true;
31
+ }
145
32
  }
146
- const id = url.slice("connection:".length);
147
- return id.length > 0 && !id.includes("/") && !id.includes(":") ? id : null;
33
+ return false;
148
34
  }
149
- // The connection ids a rule references — through `sso:{acn}` / `ext:{acn}:…`
150
- // requiredGrants or symbolic `connection:{acn}` acquire refs. The resolver
151
- // embeds these connections' keys/acquire legs at hydration time.
152
- export function referencedConnectionIds(input) {
153
- const ids = new Set();
154
- for (const grant of input.requiredGrants ?? []) {
155
- if (grant.startsWith("sso:")) {
156
- const id = grant.slice("sso:".length);
157
- if (id && !id.includes(":")) {
158
- ids.add(id);
159
- }
160
- }
161
- else if (grant.startsWith("ext:")) {
162
- const id = grant.slice("ext:".length).split(":")[0];
163
- if (id) {
164
- ids.add(id);
165
- }
166
- }
35
+ /**
36
+ * Canonicalizes the URL route identity used by People, Links, requests,
37
+ * comments, and runtime authorization.
38
+ */
39
+ export function normalizeAccessScopePath(input) {
40
+ const value = input;
41
+ if (!value.startsWith("/") || value.startsWith("//") || hasUnsafeScopeCharacter(value)) {
42
+ throw new TypeError("scope must be a plain absolute URL path");
43
+ }
44
+ let decoded;
45
+ try {
46
+ decoded = decodeURIComponent(value);
47
+ }
48
+ catch {
49
+ throw new TypeError("scope contains malformed percent encoding");
167
50
  }
168
- for (const acquire of input.acquire ?? []) {
169
- if (acquire.type === "login" && acquire.url) {
170
- const id = parseConnectionAcquireRef(acquire.url);
171
- if (id) {
172
- ids.add(id);
51
+ if (/%2f|%5c/iu.test(value) || hasUnsafeScopeCharacter(decoded)) {
52
+ throw new TypeError("scope contains an ambiguous encoded separator");
53
+ }
54
+ const segments = [];
55
+ for (const rawSegment of decoded.split("/")) {
56
+ const segment = rawSegment.normalize("NFC");
57
+ if (segment === "" || segment === ".")
58
+ continue;
59
+ if (segment === "..") {
60
+ if (segments.length === 0) {
61
+ throw new TypeError("scope escapes the URL root");
173
62
  }
63
+ segments.pop();
64
+ continue;
174
65
  }
66
+ segments.push(segment);
67
+ }
68
+ if (segments.at(-1) === "index.html") {
69
+ segments.pop();
175
70
  }
176
- return [...ids];
71
+ return segments.length === 0 ? "/" : `/${segments.join("/")}`;
177
72
  }
178
- // A raw Ed25519 public key is 32 bytes → 43 base64url chars, no padding.
179
- export const externalConnectionPublicKeySchema = z.object({
180
- kid: z.string().min(1).max(64),
181
- publicKey: z
182
- .string()
183
- .regex(/^[A-Za-z0-9_-]{43}$/, "publicKey must be a base64url raw 32-byte Ed25519 public key"),
184
- });
185
- const guestEmailDomainsSchema = z
186
- .array(z
73
+ export const accessScopePathSchema = z
187
74
  .string()
188
- .min(3)
189
- .max(255)
190
- .regex(/^[a-z0-9.-]+\.[a-z]{2,}$/i, "must be a bare domain, e.g. acme.com"))
191
- .max(20);
192
- // The connection projected onto the wire — the OIDC client secret NEVER
193
- // appears (masked after write, §4.4); `clientSecretSet` says one is stored.
194
- export const accessConnectionSchema = z.object({
195
- id: z.string().min(1),
196
- teamId: z.string().min(1),
197
- type: accessConnectionTypeSchema,
198
- name: z.string().min(1),
199
- // OIDC half (null for external connections).
200
- issuer: z.string().min(1).nullable().default(null),
201
- clientId: z.string().min(1).nullable().default(null),
202
- clientSecretSet: z.boolean().default(false),
203
- // External half (null for oidc connections). `authorizeUrl` is HTTPS-pinned
204
- // at create and admin-only to mutate.
205
- authorizeUrl: z.string().min(1).nullable().default(null),
206
- publicKeys: z.array(externalConnectionPublicKeySchema).default([]),
207
- sessionTtlSeconds: z.number().int().positive(),
208
- // Verified-email domains auto-granted team-viewer at mint (§6.2); null = none.
209
- guestEmailDomains: z.array(z.string().min(1)).nullable().default(null),
210
- createdAt: z.string().min(1),
211
- revokedAt: z.string().min(1).nullable().default(null),
212
- active: z.boolean(),
213
- });
214
- export const oidcConnectionCreateSchema = z.object({
215
- type: z.literal("oidc"),
216
- name: z.string().min(1).max(200),
217
- // The IdP issuer URL (HTTPS): discovery runs against
218
- // `{issuer}/.well-known/openid-configuration`.
219
- issuer: z.string().min(1).max(1024),
220
- clientId: z.string().min(1).max(512),
221
- clientSecret: z.string().min(1).max(2048),
222
- sessionTtlSeconds: z
223
- .number()
224
- .int()
225
- .min(60)
226
- .max(24 * 60 * 60)
227
- .optional(),
228
- guestEmailDomains: guestEmailDomainsSchema.optional(),
229
- });
230
- export const externalConnectionCreateSchema = z.object({
231
- type: z.literal("external"),
232
- name: z.string().min(1).max(200),
233
- // The shop's authorize endpoint (HTTPS-pinned): the challenge page's login
234
- // link forwards straight here for single-audience external rules.
235
- authorizeUrl: z.string().min(1).max(2048),
236
- publicKeys: z.array(externalConnectionPublicKeySchema).min(1).max(5),
237
- sessionTtlSeconds: z.number().int().min(60).max(EXTERNAL_TOKEN_MAX_TTL_SECONDS).optional(),
238
- guestEmailDomains: guestEmailDomainsSchema.optional(),
239
- });
240
- export const accessConnectionCreateSchema = z.discriminatedUnion("type", [
241
- oidcConnectionCreateSchema,
242
- externalConnectionCreateSchema,
243
- ]);
244
- // PATCH shape — fields are validated against the connection's type at the
245
- // route (`authorizeUrl`/`publicKeys` are external-only; `issuer`/`clientId`/
246
- // `clientSecret` are oidc-only). `publicKeys` REPLACES the key set (push-key
247
- // rotation: push the new key alongside the old, flip signing, drop the old).
248
- export const accessConnectionUpdateSchema = z.object({
249
- name: z.string().min(1).max(200).optional(),
250
- issuer: z.string().min(1).max(1024).optional(),
251
- clientId: z.string().min(1).max(512).optional(),
252
- clientSecret: z.string().min(1).max(2048).optional(),
253
- authorizeUrl: z.string().min(1).max(2048).optional(),
254
- publicKeys: z.array(externalConnectionPublicKeySchema).min(1).max(5).optional(),
255
- sessionTtlSeconds: z
256
- .number()
257
- .int()
258
- .min(60)
259
- .max(24 * 60 * 60)
260
- .optional(),
261
- guestEmailDomains: guestEmailDomainsSchema.nullable().optional(),
262
- });
263
- // DELETE response: revocation is structural — rules referencing the
264
- // connection lose those grants in the immediate recompile; rules left with an
265
- // empty audience are disabled (removed) and listed so the owner knows exactly
266
- // what stopped enforcing via that connection.
267
- export const accessConnectionRevokeResultSchema = z.object({
268
- connection: accessConnectionSchema,
269
- disabledRules: z
270
- .array(z.object({ spaceId: z.string().min(1), ruleId: z.string().min(1) }))
271
- .default([]),
272
- });
273
- // ---------------------------------------------------------------------------
274
- // 3. The unified runtime Rule (firewall = a rule with no auth)
275
- // ---------------------------------------------------------------------------
276
- // The `auth` object (access-plan X-32): there is exactly ONE satisfaction
277
- // test — token grants ∩ `requiredGrants`. `acquire` entries only configure how
278
- // the challenge page lets an unsatisfied visitor OBTAIN a token:
279
- //
280
- // password → the challenge page renders a password form; a valid POST mints
281
- // the standard visitor token RUNTIME-LOCALLY (space HS256 key,
282
- // `pw:{ruleId}` grant) into the one `spacefast_access` cookie.
283
- // `basic` transport is the stateless HTTP 401 variant — valid
284
- // Basic credentials satisfy `pw:{ruleId}` per-request (browsers
285
- // replay the Authorization header; no cookie needed).
286
- // login → the challenge page renders a sign-in link into the cloud
287
- // authorize chain (or a BYO issuer's authorizeUrl).
288
- //
289
- // Mixed audiences are just multiple acquire entries on one rule — the chooser
290
- // is the challenge page rendering every acquire block, never a new surface.
291
- export const passwordAcquireSchema = z.object({
292
- type: z.literal("password"),
293
- // A secret reference (e.g. "secret:space-password"), never plaintext. The
294
- // runtime resolves it from serving secrets and `password_verify`s.
295
- ref: z.string().min(1),
296
- transport: z.enum(["basic", "form"]),
297
- username: z.string().min(1).optional(),
298
- });
299
- export const loginAcquireSchema = z.object({
300
- type: z.literal("login"),
301
- // Interactive handoff target (the cloud authorize endpoint, or a BYO
302
- // connection's authorizeUrl). The challenge page links only to the runtime's
303
- // same-origin login endpoint; it never exposes this target directly. The
304
- // compiler hydrates static identity intent, then the runtime sets `host=`
305
- // from the actual serving request and `return=` as a plain same-origin path.
306
- url: z.string().min(1),
307
- // Display label for the challenge-page button (e.g. "Continue with
308
- // Spacefast", "Sign in with Acme Shop").
309
- label: z.string().min(1).optional(),
310
- });
311
- export const acquireSchema = z.discriminatedUnion("type", [
312
- passwordAcquireSchema,
313
- loginAcquireSchema,
314
- ]);
315
- export const ruleAuthSchema = z.object({
316
- // ANY-OF: the visitor's verified grants must intersect this set. Entries may
317
- // be globs (e.g. "email:*@acme.com").
318
- requiredGrants: z.array(grantSchema).min(1),
319
- // Issuer keys whose tokens are accepted for this rule. When omitted, the
320
- // compiler fills in the applicable platform/registered keys. Tokens signed by
321
- // a key contribute only grants within that key's `grantNamespaces`.
322
- issuers: z.array(accessKeySchema).optional(),
323
- // How the challenge page lets a visitor get a token. Absent = no interactive
324
- // acquisition (service-token-only rule): the runtime denies a browser rather
325
- // than loop.
326
- acquire: z.array(acquireSchema).optional(),
327
- });
328
- // Match conditions AND together (host AND path AND channel AND ipCidrs AND …).
329
- // All fields optional; an empty match matches everything in scope.
330
- export const ruleMatchSchema = z.object({
331
- host: z.string().min(1).optional(),
332
- hostPattern: z.string().min(1).optional(),
333
- // `_headers` absolute host matcher template: ":branch.example.com" keeps
334
- // placeholders single-label while "*" keeps the existing host wildcard shape.
335
- hostTemplate: z.string().min(1).optional(),
336
- // Glob path pattern: "/docs/**" | "/docs/*" | "/x.html".
337
- pathPattern: z.string().min(1).optional(),
338
- // Serving context channel name (e.g. "live", "staging").
339
- channel: z.string().min(1).optional(),
340
- // CIDR v4/v6 list; the request IP must fall in one of these.
341
- ipCidrs: z.array(z.string().min(1)).max(50).optional(),
342
- // User-agent matcher (e.g. block a crawler, gate an agent).
343
- agent: z.string().min(1).optional(),
344
- // ISO 3166-1 alpha-2 country code matcher.
345
- country: z.string().min(2).max(2).optional(),
346
- // Matches when the resolved country is NOT in this list. Unlike compiling
347
- // an allowlist as the finite ISO complement, this also matches missing,
348
- // provider-special, malformed, and future country values.
349
- countryNotIn: z.array(z.string().min(2).max(2)).min(1).max(250).optional(),
350
- // Single request-header matcher: name + expected value.
351
- header: z.object({ name: z.string().min(1), value: z.string() }).optional(),
352
- });
353
- // Managed-block owners (access-plan X-34). Block ownership is THIS schema
354
- // field, never a reasonCode convention: `managedBy` is stripped/rejected from
355
- // all user input, so a raw rule can never claim or clobber a managed block.
356
- // Cloud-lane block order: operator/incident rules first, then `firewall`, then
357
- // `sharing`/`file_share`/`cast_reviewer`, then `team_default` last.
358
- export const MANAGED_BY_VALUES = [
359
- "sharing",
360
- "firewall",
361
- "file_share",
362
- "cast_reviewer",
363
- "team_default",
364
- ];
365
- export const managedBySchema = z.enum(MANAGED_BY_VALUES);
366
- // The one Rule. `match` (AND) selects requests; `effect` is what happens on
367
- // match; `auth` (absent = anonymous firewall) gates `challenge`/`allow`.
368
- export const runtimeRuleSchema = z.object({
369
- // Stable rule id. Required wherever a grant references the rule
370
- // (`pw:{ruleId}`); the compiler assigns ids to managed rules.
371
- id: z.string().min(1).optional(),
372
- match: ruleMatchSchema.default({}),
373
- effect: z.enum(["allow", "deny", "challenge"]),
374
- // Absent = anonymous firewall rule (no credential required). Present = pass
375
- // when token grants ∩ requiredGrants ≠ ∅ (the one satisfaction test).
376
- auth: ruleAuthSchema.optional(),
377
- // Which product surface owns this rule (managed blocks). Never accepted from
378
- // user input — `replaceManagedRules` is the only writer.
379
- managedBy: managedBySchema.optional(),
380
- // Unix seconds. TTL'd interventions (`sf block --ttl`): the runtime SKIPS an
381
- // expired rule at match time (one timestamp compare, no recompile needed);
382
- // the compiler GCs expired rules on the next write.
383
- expiresAt: z.number().int().nonnegative().optional(),
384
- // Surfaced on a `deny` (X-Spacefast-Reason header) and the rendered platform
385
- // page so an operator/visitor can tell which rule fired and why. Diagnostic
386
- // only — never load-bearing for ownership or semantics.
387
- reasonCode: z.string().min(1).optional(),
388
- message: z.string().min(1).optional(),
389
- });
390
- // ---------------------------------------------------------------------------
391
- // 3b. Visitor token (the cloud "mint" half)
392
- // ---------------------------------------------------------------------------
393
- // The header `kid`/`alg` the platform signs visitor tokens with. These match
394
- // the runtime JWKS key (`spacefast-runtime-v1` / EdDSA) so the runtime's
395
- // JWKS-by-kid verify (shared/jwt.php) accepts a token the cloud minted. Do
396
- // NOT introduce a second key system — these constants name the existing one.
397
- export const ACCESS_TOKEN_KID = "spacefast-runtime-v1";
398
- export const ACCESS_TOKEN_ALG = "EdDSA";
399
- // The published JWKS path (served under /.well-known/ by the control plane and
400
- // fetched by the runtime + Cast).
401
- export const RUNTIME_JWKS_WELL_KNOWN_FILENAME = "spacefast-runtime-jwks.json";
402
- // The `kid` the runtime's SPACE-LOCAL HS256 mint signs under (access-plan
403
- // §3.1). The key derives from the space's password secret + sessionVersion and
404
- // may only sign `pw:` grants — the same namespace filter that bounds BYO keys
405
- // bounds this one. Never published in any JWKS; verification re-derives the
406
- // key from serving secrets.
407
- export const LOCAL_PW_TOKEN_KID = "spacefast-local-pw-v1";
408
- // The claim set carried by a minted visitor token. `grants` are concrete
409
- // (no `*`), `email:`/`user:`/`team:<teamId>:<role>`/`sub-team:<subTeamId>:<role>` namespaced so they survive
410
- // the platform issuer's `grantNamespaces` filter at the runtime. `aud`, when
411
- // present, lowercases-binds the token to a single serving host.
412
- export const accessTokenPayloadSchema = z.object({
413
- // Subject: the authenticated platform user id the token was minted for
414
- // (or "invite:sin_x" / "link:lnk_x" / "pw:anon" for account-less audiences).
415
- sub: z.string().min(1),
416
- // Concrete (non-glob) grants. The runtime intersects these with a rule's
417
- // `requiredGrants`; the visitor passes when the intersection is non-empty.
418
- grants: z.array(grantSchema).default([]),
419
- // Unix seconds. Mirrors the runtime's exp check (300s leeway).
420
- exp: z.number().int().nonnegative(),
421
- nbf: z.number().int().nonnegative().optional(),
422
- iat: z.number().int().nonnegative().optional(),
423
- iss: z.string().min(1).optional(),
424
- // Optional host binding (lowercased) — when set the token is only accepted on
425
- // that serving host.
426
- aud: z.string().min(1).optional(),
427
- // Space session version (access-plan §3.1): the runtime rejects a visitor
428
- // token whose `sv` mismatches `policy.sessionVersion`. Logout-all is an `sv`
429
- // bump — structural, TTL-independent revocation. Absent = 0.
430
- sv: z.number().int().nonnegative().optional(),
431
- // Single-use id for authorize-chain callback handoffs (access-plan X-29):
432
- // the runtime callback consumes `jti` in its replay cache, so a token lifted
433
- // from browser history is dead after first use. Share tokens carry no `jti`
434
- // (the share URL IS the credential; revocation is structural).
435
- jti: z.string().min(1).optional(),
436
- // Cast room capability. Collaboration tokens are valid for exactly one
437
- // canonical room; other visitor tokens omit it.
438
- room: z.string().min(1).optional(),
439
- // Anonymous Comments tokens remain anonymous identities even though they
440
- // carry a signed room capability.
441
- anonymous_id: z.string().min(1).optional(),
442
- name: z.string().min(1).max(120).optional(),
443
- });
444
- // Authorize request for `POST /v1/spaces/:id/access/authorize` — surviving for
445
- // exactly one caller: the dashboard preview-token mint (iframe → runtime
446
- // callback). It never sets cookies; only the runtime callback sets the visitor
447
- // cookie. `aud` binds the token to the previewed host.
448
- export const authorizeRequestSchema = z.object({
449
- aud: z.string().min(1).optional(),
450
- });
451
- // Authorize response: the signed compact JWT plus its decoded claims (so a
452
- // caller can show/inspect the grants without re-parsing).
453
- export const authorizeResponseSchema = z.object({
454
- // The signed compact EdDSA JWT (`header.claims.signature`, base64url).
455
- token: z.string().min(1),
456
- // Decoded claims of `token` (echo of what was signed).
457
- payload: accessTokenPayloadSchema,
458
- // Unix seconds the token expires at (convenience mirror of `payload.exp`).
459
- expiresAt: z.number().int().nonnegative(),
460
- });
461
- // ---------------------------------------------------------------------------
462
- // 3b'. Access events (access-plan §5.6b, X-37)
463
- // ---------------------------------------------------------------------------
464
- // The access event is a first-class contract from day one: the runtime emits
465
- // one per enforced decision into the per-hostname analytics ingestion; the
466
- // Views panel is a reader of an already-flowing stream. `grantsHash` is a
467
- // sha256 of the sorted verified grant list (never the grants themselves).
468
- export const accessEventSchema = z.object({
469
- sub: z.string().min(1).nullable().default(null),
470
- grantsHash: z.string().min(1).nullable().default(null),
471
- ruleId: z.string().min(1).nullable().default(null),
472
- effect: z.enum(["allow", "deny", "challenge"]),
473
- reasonCode: z.string().min(1).nullable().default(null),
474
- host: z.string().min(1),
475
- path: z.string().min(1),
476
- // Unix seconds.
477
- ts: z.number().int().nonnegative(),
75
+ .min(1)
76
+ .max(1024)
77
+ .transform((value, context) => {
78
+ try {
79
+ return normalizeAccessScopePath(value);
80
+ }
81
+ catch (error) {
82
+ context.addIssue({
83
+ code: "custom",
84
+ message: error instanceof Error ? error.message : "invalid access scope",
85
+ });
86
+ return z.NEVER;
87
+ }
478
88
  });
479
- // ---------------------------------------------------------------------------
480
- // 3c. Mint-time grant builders (the cloud "resolve identity → grants" half)
481
- // ---------------------------------------------------------------------------
482
- // The leveled space grant (access-plan X-38): `space:{spaceId}:{level}` where
483
- // level SPACE_LEVELS. The `space:` namespace is on the platform issuer, so
484
- // the runtime keeps it; Cast keys its capability table off this grant; the
485
- // accepted-invite path mints the `viewer` level.
486
- export function spaceLevelGrant(spaceId, level) {
487
- return `space:${spaceId}:${level}`;
488
- }
489
- // The concrete grant a share-link holder carries: a token with `link:{linkId}`
490
- // intersects the `sharing:`-block rule whose `requiredGrants` the compiler
491
- // fills from the ACTIVE link rows (§5.2). Revoking the link recompiles it out
492
- // and the token goes inert. The platform issuer lists the `link:` namespace.
493
- export function shareLinkGrant(linkId) {
494
- return `link:${linkId}`;
89
+ export function accessScopeContains(scope, path) {
90
+ const canonicalScope = normalizeAccessScopePath(scope);
91
+ const canonicalPath = normalizeAccessScopePath(path);
92
+ return (canonicalScope === "/" ||
93
+ canonicalPath === canonicalScope ||
94
+ canonicalPath.startsWith(`${canonicalScope}/`));
495
95
  }
496
- // The concrete grant an invited viewer carries: `invite:{inviteId}` compiled
497
- // per active invite (§5.3); revocation is structural.
498
- export function inviteGrant(inviteId) {
499
- return `invite:${inviteId}`;
500
- }
501
- // The concrete grant a service token carries: `svc:{tokenId}` compiled
502
- // wherever the token's `actsAs` satisfies (§5.5); revocation is structural.
503
- export function serviceTokenGrant(tokenId) {
504
- return `svc:${tokenId}`;
96
+ export const authorityReferenceSchema = z
97
+ .string()
98
+ .regex(/^(member|person|link|password|machine|claim-preview|external):[A-Za-z0-9_.-]+$/);
99
+ export const personGrantSchema = z.object({
100
+ id: z.string().min(1),
101
+ scope: accessScopePathSchema,
102
+ role: accessRoleSchema,
103
+ createdAt: z.string().datetime(),
104
+ revokedAt: z.string().datetime().nullable(),
105
+ });
106
+ export const spacePersonStatusSchema = z.enum(["pending", "accepted", "revoked"]);
107
+ export const spacePersonDisplayNameSchema = z.string().trim().min(1).max(160);
108
+ export function fallbackSpacePersonDisplayName(personId) {
109
+ const suffix = personId.replace(/[^A-Za-z0-9]/gu, "").slice(-6);
110
+ return suffix ? `Guest ${suffix}` : "Guest";
505
111
  }
506
- // ---------------------------------------------------------------------------
507
- // 3d. Share links + the sharing projection (access-plan §5.1/§5.2, X-33)
508
- // ---------------------------------------------------------------------------
509
- // Share tokens are re-signed on demand from the link row (§5.2): the JWT is
510
- // never stored, only the row. Credential lifetime = artifact lifetime: `exp`
511
- // IS the link's expiry and is omitted for a never-expiring link. The runtime
512
- // permits that omission only for a signed, audience-bound token whose subject
513
- // and sole grant are the same `link:` capability. Revocation remains
514
- // structural — the grant drops from the compiled rules.
515
- // A share link row projected onto the wire (§4.4). `path` is "/" for a
516
- // space-wide link; any other value scopes the link's grant to that subtree
517
- // (compile-time: the grant only ever appears in rules matching the path).
518
- // `lastUsedAt`/`useCount` are the operational-observability columns (X-28);
519
- // the runtime-side `?sf_share=` trade increments them offline, so they read 0
520
- // until that ingestion lands.
521
- export const shareLinkSchema = z.object({
112
+ export const spacePersonSchema = z.object({
522
113
  id: z.string().min(1),
523
114
  spaceId: z.string().min(1),
524
- label: z.string().min(1).nullable().default(null),
525
- path: z.string().min(1),
526
- createdBy: z.string().min(1).nullable().default(null),
527
- // Unix seconds; null = the link never expires. The minted token mirrors this
528
- // by omitting exp — there is no hidden shorter fuse.
529
- expiresAt: z.number().int().nonnegative().nullable().default(null),
530
- createdAt: z.string().min(1).describe("ISO 8601 timestamp."),
531
- lastUsedAt: z.string().min(1).nullable().default(null),
532
- useCount: z.number().int().nonnegative().default(0),
533
- // false once revoked or past expiry — the grant is no longer compiled.
534
- active: z.boolean(),
535
- });
536
- export const shareLinkCreateSchema = z.object({
537
- // A plain absolute path ("/" = the whole space).
538
- path: z.string().min(1).max(1024).default("/"),
539
- // Unix seconds. Omitted = no expiry (structural revocation still applies).
540
- expiresAt: z.number().int().positive().optional(),
541
- label: z.string().min(1).max(200).optional(),
542
- });
543
- // Returned on create and on the explicit re-copy intent ONLY — list reads never
544
- // carry the token-bearing URL (§5.2: share-URL responses are no-store).
545
- export const shareLinkWithUrlSchema = shareLinkSchema.extend({
546
- url: z.string().min(1).describe("https://<host><path>?sf_share=<token>"),
547
- });
548
- // Who can open the space — ONE exclusive bit, always explicit. Everything
549
- // else (password, share links, invites, connection audiences, service tokens)
550
- // is an additive KEY into a restricted space: union semantics, any key
551
- // admits; keys are dormant on a public space, never errors. Visibility is
552
- // DERIVED from the `sharing:` managed block (a restricted space is one whose
553
- // block carries the space-wide challenge rule) — never stored separately.
554
- export const SPACE_VISIBILITIES = ["public", "restricted"];
555
- export const spaceVisibilitySchema = z.enum(SPACE_VISIBILITIES);
556
- // A connection audience member on the People-only selector state (§6.2/§6.3):
557
- // "your company SSO" (oidc — optionally narrowed to a verified email domain)
558
- // or "Acme Shop subscribers" (external — the owner-defined grant tag).
559
- export const sharingConnectionAudienceSchema = z.object({
560
- connectionId: z.string().min(1),
561
- // External connections: the grant tag ("wc-subscriber" → ext:{acn}:{tag}).
562
- // Required for external, rejected for oidc.
563
- tag: z
564
- .string()
115
+ displayName: spacePersonDisplayNameSchema,
116
+ email: z.string().email().nullable(),
117
+ status: spacePersonStatusSchema,
118
+ grants: z.array(personGrantSchema),
119
+ invitedBy: z.string().nullable(),
120
+ inviteExpiresAt: z.string().datetime().nullable(),
121
+ acceptedAt: z.string().datetime().nullable(),
122
+ revokedAt: z.string().datetime().nullable(),
123
+ createdAt: z.string().datetime(),
124
+ });
125
+ export const inheritedTeamAccessSchema = z.object({
126
+ teamId: z.string().min(1),
127
+ teamName: z.string().min(1),
128
+ ruleScopes: z
129
+ .array(accessScopePathSchema)
565
130
  .min(1)
566
- .max(100)
567
- .regex(/^[A-Za-z0-9._-]+$/)
568
- .optional(),
569
- // OIDC connections: narrow the audience to a verified email domain — the
570
- // rule compiles `email:*@{domain}` instead of `sso:{acn}` (the SSO mint
571
- // carries the verified `email:` grant). Rejected for external.
572
- emailDomain: z
573
- .string()
574
- .min(3)
575
- .max(255)
576
- .regex(/^[a-z0-9.-]+\.[a-z]{2,}$/i)
577
- .optional(),
578
- });
579
- // PUT …/sharing/visibility — the ONE exclusive choice. Flipping visibility
580
- // never creates or destroys keys: restricting re-arms whatever keys exist;
581
- // going public leaves them dormant (including a stored password).
582
- export const visibilityWriteSchema = z.object({
583
- visibility: spaceVisibilitySchema,
584
- });
585
- // PUT …/sharing/password — set/replace the password KEY. Setting a password
586
- // also restricts a public space (the intent is unambiguous); it never touches
587
- // other keys. DELETE clears the key and leaves visibility alone (fail-closed:
588
- // a restricted space stays restricted, team-only if that was the last key).
589
- export const sharingPasswordWriteSchema = z.object({
590
- password: z.string().min(1).max(256),
591
- });
592
- // PUT …/sharing/audiences — replace the connection-audience keys (§6.2/§6.3).
593
- // Compiled INTO the one sharing general rule (mixed audiences = one rule,
594
- // multiple acquires — the chooser is the challenge page, never a new surface).
595
- export const sharingAudiencesWriteSchema = z.object({
596
- audiences: z.array(sharingConnectionAudienceSchema).max(20),
597
- });
598
- // The core protection op (agent surface): visibility plus the password key in
599
- // one write. `password: string` sets the key (and restricts), `null` clears
600
- // it, omitted leaves it alone. Deeper key management (links, invites,
601
- // audiences) lives on the sharing surface.
602
- export const spaceProtectionWriteSchema = z.object({
603
- visibility: spaceVisibilitySchema,
604
- password: z.string().min(1).max(256).nullable().optional(),
605
- });
606
- // A connection audience projected back on the sharing read (derived from the
607
- // compiled block — never stored separately, X-33).
608
- export const sharingAudienceSchema = z.object({
609
- connectionId: z.string().min(1),
610
- type: accessConnectionTypeSchema,
611
- // Connection display name (null when the connection row is gone).
612
- name: z.string().min(1).nullable().default(null),
613
- tag: z.string().min(1).nullable().default(null),
614
- emailDomain: z.string().min(1).nullable().default(null),
615
- // The compiled requiredGrants entry this audience contributes.
616
- grant: grantSchema,
617
- });
618
- // A row in the Share modal's People list. Team members are badged; invitees
619
- // (§5.3) carry their invite id + status so the modal can revoke per-row and
620
- // badge pending/accepted/expired.
621
- export const sharingPersonSchema = z.object({
622
- kind: z.enum(["team_member", "invitee"]),
623
- userId: z.string().min(1).nullable().default(null),
624
- email: z.string().min(1).nullable().default(null),
625
- name: z.string().min(1).nullable().default(null),
626
- // Team management role for members (owner|admin|member); null for invitees.
627
- teamRole: z.string().min(1).nullable().default(null),
628
- // Space level for invitees (viewer in v1); null for team members.
629
- level: spaceLevelSchema.nullable().default(null),
630
- // The sin_ invite id for invitees (per-row revoke); null for team members.
631
- inviteId: z.string().min(1).nullable().default(null),
632
- // Invite lifecycle for invitees ("expired" = the accept LINK lapsed
633
- // unaccepted; access-grant compilation is structural, not TTL'd); null for
634
- // team members. Revoked invites never appear in the People list.
635
- status: z.enum(["pending", "accepted", "expired"]).nullable().default(null),
636
- // Operational observability (X-28): per-invitee "last viewed"; null until
637
- // the Views ingestion feeds it.
638
- lastViewedAt: z.string().min(1).nullable().default(null),
639
- });
640
- // ---------------------------------------------------------------------------
641
- // 3d'. Invites (access-plan §5.3) + access requests (§5.4)
642
- // ---------------------------------------------------------------------------
643
- // Accept-link validity (§4.4): how long the emailed magic link mints tokens.
644
- // Access itself is structural (the invite grant stays compiled until revoke);
645
- // an expired link only means "re-mail me a new one".
646
- export const INVITE_ACCEPT_LINK_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
647
- // The visitor token an accept mints (§5.3): 30 days, re-entry by re-clicking
648
- // the emailed link (X-8: no guest sessions, the inbox is the credential).
649
- export const INVITE_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days
650
- // Invite/request personal messages (X-25): ≤280 chars, plain text only —
651
- // stored as typed, HTML-escaped at every render (email + pages), never
652
- // linkified, rendered visually subordinate to the platform frame.
653
- export const ACCESS_MESSAGE_MAX_LENGTH = 280;
654
- export const INVITE_STATUSES = ["pending", "accepted", "expired", "revoked"];
655
- export const inviteStatusSchema = z.enum(INVITE_STATUSES);
656
- // A space invite projected onto the wire (§4.4). The accept token is NEVER
657
- // stored or returned — only its hash is at rest, and the token travels solely
658
- // in the invitee's email (possession of the inbox IS the credential). `email`
659
- // is null once revoked (X-25: invitee addresses are PII of non-users and are
660
- // purged on revoke; the row survives for audit).
661
- export const spaceInviteSchema = z.object({
662
- id: z.string().min(1),
663
- spaceId: z.string().min(1),
664
- email: z.string().min(1).nullable().default(null),
665
- invitedBy: z.string().min(1).nullable().default(null),
666
- message: z.string().nullable().default(null),
667
- status: inviteStatusSchema,
668
- // Unix seconds the accept LINK stops minting (re-mail extends it).
669
- expiresAt: z.number().int().nonnegative(),
670
- acceptedAt: z.string().min(1).nullable().default(null),
671
- createdAt: z.string().min(1),
672
- // Operational observability (X-28): honest null until the Views ingestion.
673
- lastViewedAt: z.string().min(1).nullable().default(null),
674
- });
675
- export const spaceInviteCreateSchema = z.object({
676
- email: z.string().min(3).max(320),
677
- message: z.string().max(ACCESS_MESSAGE_MAX_LENGTH).optional(),
678
- });
679
- export const ACCESS_REQUEST_STATUSES = ["pending", "approved", "denied"];
680
- export const accessRequestStatusSchema = z.enum(ACCESS_REQUEST_STATUSES);
681
- // An access request (§5.4): a signed-in-but-unsatisfied visitor asking to be
682
- // let in from the 403 page. Coalesced on (spaceId, email) while pending.
683
- export const accessRequestSchema = z.object({
131
+ .describe("Explicit Team rule locations, not effective subtree coverage."),
132
+ members: z.array(z.object({
133
+ membershipId: z.string().min(1),
134
+ userId: z.string().min(1),
135
+ displayName: z.string().min(1),
136
+ email: z.string().email(),
137
+ teamRole: z.string().min(1),
138
+ role: z.literal("editor"),
139
+ })),
140
+ });
141
+ export const spacePeopleListSchema = z.object({
142
+ people: z.array(spacePersonSchema),
143
+ inheritedTeamAccess: inheritedTeamAccessSchema.nullable(),
144
+ });
145
+ export const accessShareLinkSchema = z.object({
684
146
  id: z.string().min(1),
685
147
  spaceId: z.string().min(1),
686
- email: z.string().min(1),
687
- userId: z.string().min(1).nullable().default(null),
688
- host: z.string().min(1),
689
- path: z.string().min(1),
690
- message: z.string().nullable().default(null),
691
- status: accessRequestStatusSchema,
692
- resolvedBy: z.string().min(1).nullable().default(null),
693
- resolvedAt: z.string().min(1).nullable().default(null),
694
- createdAt: z.string().min(1),
695
- });
696
- // POST /v1/access/requests — the 403-page form's target. Identity (email +
697
- // userId) comes from the SESSION, never the body: the authorize chain
698
- // guarantees requesters are identified (anonymous → 401).
699
- export const accessRequestCreateSchema = z.object({
700
- space: z.string().min(1),
701
- host: z.string().min(1).max(255),
702
- path: z.string().max(1024).default("/"),
703
- nonce: z.string().min(1).max(2048).optional(),
704
- message: z.string().max(ACCESS_MESSAGE_MAX_LENGTH).optional(),
705
- });
706
- // POST …/requests/:id/approve — the chooser (§5.4): "invite" reuses the §5.3
707
- // magic-link invite; "team" is the Better-Auth team invitation.
708
- export const accessRequestApproveSchema = z.object({
709
- as: z.enum(["invite", "team"]).default("invite"),
710
- });
711
- // File-mode sharing (§5.1 files-mode merge): the dashboard-side file-share
712
- // rule compiler is dead; file shares are a server-side intent surface owning
713
- // the `file_share` managed block. A record is keyed by the file/folder PATH;
714
- // the server expands the serving-path aliases (exact, directory index,
715
- // extensionless .html variant) at compile time.
716
- export const FILE_SHARE_AUDIENCES = ["public", "people"];
717
- export const fileShareAudienceSchema = z.enum(FILE_SHARE_AUDIENCES);
718
- // The projected file-share record (read shape on the sharing projection).
719
- export const fileShareSchema = z.object({
720
- // Canonical absolute file path ("/report/deck.pdf") or folder path ("/report/").
721
- path: z.string().min(1),
722
- audience: fileShareAudienceSchema,
723
- // The email: grants admitted when audience = "people"; empty for "public".
724
- grants: z.array(grantSchema).default([]),
725
- // The compiled serving-path alias patterns the record covers (diagnostic).
726
- patterns: z.array(z.string().min(1)).default([]),
727
- });
728
- // PUT /v1/spaces/:id/file-shares — upsert the record for one path. `grants`
729
- // accepts raw emails or `email:` grants; the server normalizes + validates.
730
- export const fileShareWriteSchema = z.object({
731
- path: z.string().min(1).max(1024),
732
- audience: fileShareAudienceSchema,
733
- grants: z.array(z.string().min(1)).max(100).optional(),
734
- });
735
- // A pending access request row on the sharing projection (§5.4). `host`/`path`
736
- // say where the requester was headed so the admin has context.
737
- export const sharingRequestSchema = z.object({
738
- id: z.string().min(1),
739
- email: z.string().min(1),
740
- message: z.string().nullable().default(null),
741
- createdAt: z.string().min(1),
742
- host: z.string().min(1).nullable().default(null),
743
- path: z.string().min(1).nullable().default(null),
744
- });
745
- // ---------------------------------------------------------------------------
746
- // 3d''. Service tokens (access-plan §4.4/§5.5 — machine principals)
747
- // ---------------------------------------------------------------------------
748
- // Service tokens are the machine-principal leg of the IAM table (§5.5):
749
- // `stk_` rows, Bearer-only, shown ONCE on create, `svc:{id}` grants compiled
750
- // wherever the token's `actsAs` satisfies, structural revocation (X-7 —
751
- // revoke recompiles the grant out; the credential's TTL is irrelevant).
752
- //
753
- // The secret IS a platform-signed visitor JWT ({sub: "svc:stk_x", grants:
754
- // ["svc:stk_x"], exp: expiresAt | now+10y}) — the runtime's Bearer path
755
- // already verifies visitor JWTs before the cookie (§3.2), so CI/agents fetch
756
- // protected artifacts with `Authorization: Bearer <token>` and ZERO runtime
757
- // work. Only the token's sha256 is stored (lastUsedAt attribution/display);
758
- // the plaintext never persists.
759
- //
760
- // `sv` posture (documented, accepted): a space-scoped token pins the space's
761
- // sessionVersion at mint; a team-wide token carries sv 0 (the absent-sv
762
- // default). A space "logout-all" (sv bump) therefore ALSO rotates service
763
- // access to that space — re-create the token. Arguably correct: logout-all
764
- // means "everyone out", and service tokens are visitors too.
765
- export const SERVICE_TOKEN_DEFAULT_TTL_SECONDS = 10 * 365 * 24 * 60 * 60; // 10 years
766
- // The audience classes a service token can act as. v1 has exactly one:
767
- // "viewers" — the token satisfies every managed rule that admits the
768
- // team-viewer audience (the sharing block's general rule in `link`/`people`
769
- // modes and file-share `people` rules). It does NOT satisfy a password wall
770
- // (`pw:` is password-only, like team members) or raw rules that don't name
771
- // `svc:` grants.
772
- export const SERVICE_TOKEN_AUDIENCES = ["viewers"];
773
- export const serviceTokenAudienceSchema = z.enum(SERVICE_TOKEN_AUDIENCES);
774
- export const serviceTokenActsAsSchema = z.object({
775
- audiences: z.array(serviceTokenAudienceSchema).min(1).default(["viewers"]),
776
- });
777
- // A service token projected onto the wire — NEVER carries the secret; the
778
- // signed JWT appears exactly once, on the create response.
779
- export const serviceTokenSchema = z.object({
780
- id: z.string().min(1),
781
- teamId: z.string().min(1),
782
- // null = team-wide (the grant compiles into every team space).
783
- spaceId: z.string().min(1).nullable().default(null),
148
+ grantId: z.string().min(1),
784
149
  name: z.string().min(1),
785
- actsAs: serviceTokenActsAsSchema,
786
- // Unix seconds; null = the 10-year default ceiling applies.
787
- expiresAt: z.number().int().nonnegative().nullable().default(null),
788
- // Operational observability (X-28, mirroring share links): fed by the
789
- // runtime access-event ingestion; honest null until that reader lands.
790
- lastUsedAt: z.string().min(1).nullable().default(null),
791
- createdAt: z.string().min(1),
792
- // false once revoked or past expiry — the grant is no longer compiled.
150
+ landingPath: accessScopePathSchema,
151
+ resources: grantResourceSetSchema,
152
+ capabilities: z.array(z.enum(["page.view", "comments.read", "comments.write"])).min(1),
153
+ constraints: grantConstraintsSchema,
154
+ target: grantTargetSelectorSchema,
155
+ createdBy: z.string().nullable(),
156
+ openCount: z.number().int().nonnegative(),
157
+ lastUsedAt: z.string().datetime().nullable(),
158
+ revokedAt: z.string().datetime().nullable(),
159
+ createdAt: z.string().datetime(),
793
160
  active: z.boolean(),
794
161
  });
795
- export const serviceTokenCreateSchema = z.object({
796
- name: z.string().min(1).max(200),
797
- // Scope the grant to one space; omitted = team-wide.
798
- spaceId: z.string().min(1).optional(),
799
- // Unix seconds; omitted = the 10-year default. Revocation stays structural
800
- // either way — expiry is a hygiene ceiling, not the revocation mechanism.
801
- expiresAt: z.number().int().positive().optional(),
802
- actsAs: serviceTokenActsAsSchema.optional(),
803
- });
804
- // Returned ONLY on create (no-store): the signed Bearer JWT, shown once.
805
- export const serviceTokenWithSecretSchema = serviceTokenSchema.extend({
806
- token: z.string().min(1),
162
+ export const accessShareLinkWithUrlSchema = accessShareLinkSchema.extend({
163
+ url: z.string().url(),
807
164
  });
808
- // ---------------------------------------------------------------------------
809
- // 3d'''. The firewall card document (access-plan §5.6, X-15)
810
- // ---------------------------------------------------------------------------
811
- // Firewall is rows, not a subsystem (X-15): this document is the SIMPLE
812
- // altitude — a server-side intent surface that compiles to plain anonymous
813
- // (deny) or team-viewer challenge ("must sign in") rules in the `firewall`
814
- // managed block, ordered before `sharing` so a block always beats a link.
815
- // The projection is DERIVED from the block (X-33-style); rules in the block
816
- // the projection doesn't recognize flip `custom` and the card sends the user
817
- // to the raw-rules editor — never to a feature request.
818
- export const FIREWALL_EFFECTS = ["deny", "challenge"];
819
- export const firewallEffectSchema = z.enum(FIREWALL_EFFECTS);
820
- // Country section: `block` denies/challenges the listed countries; `allow`
821
- // admits ONLY the listed countries (one fail-closed `countryNotIn` rule).
822
- export const firewallCountriesSchema = z.object({
823
- mode: z.enum(["block", "allow"]),
824
- // ISO 3166-1 alpha-2, uppercased server-side.
825
- list: z
826
- .array(z
827
- .string()
828
- .length(2)
829
- .refine((code) => ISO_COUNTRY_CODES.includes(code.toUpperCase()), "Country must be an ISO 3166-1 alpha-2 code."))
165
+ export const canonicalAccessRequestStatusSchema = z.enum([
166
+ "verification_pending",
167
+ "pending",
168
+ "approved",
169
+ "denied",
170
+ "canceled",
171
+ "expired",
172
+ ]);
173
+ export const accessRequestRoleSchema = accessRoleSchema;
174
+ export const canonicalAccessRequestSchema = z.object({
175
+ id: z.string().min(1),
176
+ spaceId: z.string().min(1),
177
+ displayName: spacePersonDisplayNameSchema.nullable(),
178
+ email: z.string().email(),
179
+ scope: accessScopePathSchema,
180
+ requestedRole: accessRequestRoleSchema,
181
+ message: z.string().nullable(),
182
+ status: canonicalAccessRequestStatusSchema,
183
+ emailVerifiedAt: z.string().datetime().nullable(),
184
+ resolvedBy: z.string().nullable(),
185
+ resolvedAt: z.string().datetime().nullable(),
186
+ createdAt: z.string().datetime(),
187
+ updatedAt: z.string().datetime(),
188
+ });
189
+ export const accessFenceKindValues = ["none", "ownership", "exposure"];
190
+ export const accessFenceKindSchema = z.enum(accessFenceKindValues);
191
+ export const ACCESS_GRANT_SCOPE_LIMIT = 32;
192
+ export const linkCapabilitiesSchema = z
193
+ .array(z.enum(["page.view", "comments.read", "comments.write"]))
194
+ .min(1)
195
+ .refine((capabilities) => capabilities.includes("page.view"), {
196
+ message: "Link capabilities must include page.view",
197
+ })
198
+ .refine((capabilities) => new Set(capabilities).size === capabilities.length, {
199
+ message: "Link capabilities must be unique",
200
+ });
201
+ export const spacePersonInviteWriteSchema = z.object({
202
+ email: z.string().email(),
203
+ grants: z
204
+ .array(z.object({ scope: accessScopePathSchema, role: accessRoleSchema }))
830
205
  .min(1)
831
- .max(250),
832
- // deny = blocked outright; challenge = "visitors from these countries must
833
- // sign in" (the team-viewer audience per §5.6).
834
- effect: firewallEffectSchema.default("deny"),
835
- });
836
- export const firewallIpEntrySchema = z.object({
837
- // CIDR ("203.0.113.0/24", v4 or v6) or a bare IP (exact match).
838
- cidr: z.string().min(1).max(64),
839
- effect: firewallEffectSchema.default("deny"),
840
- });
841
- export const firewallBotEntrySchema = z.object({
842
- // Case-insensitive user-agent substring (the runtime's `agent` matcher).
843
- agent: z.string().min(1).max(200),
844
- effect: firewallEffectSchema.default("deny"),
845
- });
846
- // PUT /v1/spaces/:id/access/firewall replaces the recognized firewall-card
847
- // rules; unrecognized (raw-authored… impossible, managedBy is server-only —
848
- // i.e. legacy/operator-written) firewall-block rules are PRESERVED and keep
849
- // the projection's `custom` flag on.
850
- export const firewallWriteSchema = z.object({
851
- countries: firewallCountriesSchema.nullable().default(null),
852
- ipCidrs: z.array(firewallIpEntrySchema).max(50).default([]),
853
- bots: z.array(firewallBotEntrySchema).max(50).default([]),
854
- });
855
- // GET /v1/spaces/:id/access/firewall — the projection derived from the block.
856
- export const spaceFirewallSchema = z.object({
857
- countries: firewallCountriesSchema.nullable().default(null),
858
- ipCidrs: z.array(firewallIpEntrySchema).default([]),
859
- bots: z.array(firewallBotEntrySchema).default([]),
860
- // True when the firewall block holds rules this document can't express —
861
- // the card links to the raw-rules editor instead of lying.
862
- custom: z.boolean().default(false),
863
- });
864
- // ---------------------------------------------------------------------------
865
- // 3d''''. Team preview-lock (access-plan §4.2/X-24 flagship default)
866
- // ---------------------------------------------------------------------------
867
- // "Non-live channels are team-only", configured once for a whole team. The
868
- // rule `match.channel` is an exact name with no negation, so NOT-live is
869
- // expressed structurally inside the team_default block (evaluated LAST in the
870
- // cloud lane): an anonymous allow pinned to the live channel, then a
871
- // catch-all team-viewer challenge. Live requests terminate on the allow
872
- // (identical to the no-rule-matches default); anything on another channel
873
- // falls through to the lock. Because the block is last, every explicit space
874
- // rule still wins first-match before either rule.
875
- //
876
- // These rules are canonical vocabulary (single-homed here): the dashboard
877
- // toggle and the CLI post exactly these rows through the EXISTING
878
- // PUT /v1/teams/:id/access/defaults — no second compiler exists.
879
- export const TEAM_PREVIEW_LOCK_LIVE_RULE_ID = "team-preview-lock:live";
880
- export const TEAM_PREVIEW_LOCK_RULE_ID = "team-preview-lock";
881
- export function teamPreviewLockRules(teamId) {
882
- return [
883
- {
884
- id: TEAM_PREVIEW_LOCK_LIVE_RULE_ID,
885
- match: { channel: "live" },
886
- effect: "allow",
887
- reasonCode: "team_preview_lock",
888
- },
889
- {
890
- id: TEAM_PREVIEW_LOCK_RULE_ID,
891
- match: {},
892
- effect: "challenge",
893
- auth: { requiredGrants: [`team:${teamId}:viewer`] },
894
- reasonCode: "team_preview_lock",
895
- message: "Non-live channels on this team are team-only.",
896
- },
897
- ];
898
- }
899
- export function isTeamPreviewLockRule(rule) {
900
- return rule.id === TEAM_PREVIEW_LOCK_LIVE_RULE_ID || rule.id === TEAM_PREVIEW_LOCK_RULE_ID;
901
- }
902
- // Whether a team-defaults rule list carries the preview lock.
903
- export function teamPreviewLockActive(rules) {
904
- return rules.some((rule) => rule.id === TEAM_PREVIEW_LOCK_RULE_ID);
905
- }
906
- // Toggle helper: returns the defaults list with the lock applied/removed,
907
- // preserving every other default rule (lock rules ride LAST so other
908
- // defaults keep first-match precedence within the block).
909
- export function withTeamPreviewLock(rules, teamId, enabled) {
910
- const kept = rules.filter((rule) => !isTeamPreviewLockRule(rule));
911
- return enabled ? [...kept, ...teamPreviewLockRules(teamId)] : kept;
912
- }
913
- // GET /v1/spaces/:id/sharing — the server-owned projection (X-33): the
914
- // dashboard renders this and posts intents; it never interprets or compiles
915
- // rules.
916
- export const spaceSharingSchema = z.object({
917
- // The one exclusive bit. Everything below it is keys.
918
- visibility: spaceVisibilitySchema,
919
- // Whether the password KEY exists (the verifier hash, never the password).
920
- // Independent of visibility: a public space can hold a dormant password.
921
- hasPassword: z.boolean(),
922
- people: z.array(sharingPersonSchema).default([]),
923
- // Connection audiences on the People-only state (§6.2/§6.3), derived from
924
- // the compiled sharing block.
925
- audiences: z.array(sharingAudienceSchema).default([]),
926
- links: z.array(shareLinkSchema).default([]),
927
- // Per-path file shares (the `file_share` managed block projected back).
928
- fileShares: z.array(fileShareSchema).default([]),
929
- requests: z.array(sharingRequestSchema).default([]),
930
- // True when rules OUTSIDE the sharing block gate the space (unmanaged cloud
931
- // rules or file-lane rules): the modal shows the "custom access rules
932
- // active" chip linking to the access settings.
933
- customRulesActive: z.boolean(),
934
- // Plan-downgrade marker (X-23), mirrored from the access lane read.
935
- accessExceedsPlan: z.boolean().default(false),
936
- });
937
- // ---------------------------------------------------------------------------
938
- // 4. Lanes / nouns (additive types only — not wired)
939
- // ---------------------------------------------------------------------------
940
- // A channel is a named serving lane (e.g. "live", "staging") pointing at a
941
- // version, with promote/rollback history.
942
- export const channelSchema = z.object({
943
- name: z.string().min(1),
944
- versionId: z.string().min(1),
945
- // Newest-first promote/rollback log: which version this channel pointed at.
946
- history: z
947
- .array(z.object({
948
- versionId: z.string().min(1),
949
- at: z.string().min(1).describe("ISO 8601 timestamp."),
950
- }))
951
- .default([]),
952
- });
953
- // Content-lane serving config (scalar, field-ownership / SSA semantics). No
954
- // `spa` toggle — that mode is gone; a SPA is expressed as a `fallback`.
955
- export const servingConfigSchema = z.object({
956
- // Directory index document (e.g. "index.html").
957
- index: z.string().min(1).optional(),
958
- // Single fallback document served when no file/route matches (replaces the
959
- // old `spa` boolean).
960
- fallback: z.string().min(1).optional(),
961
- // Whether directory listings are rendered when there is no index.
962
- listing: z.boolean().optional(),
963
- });
964
- // The policy-lane document: an ordered, additive list of unified Rules. This is
965
- // the firewall+access lane (own version + rollback, independent of content).
966
- export const policyDocSchema = z.object({
967
- rules: z.array(runtimeRuleSchema).default([]),
968
- // Space-wide verifier issuers used by first-party access surfaces
969
- // (callback/me/token), including public spaces that have no auth rules.
970
- issuers: z.array(accessKeySchema).optional(),
971
- // Runtime-serving session version. The enforcer rejects visitor tokens whose
972
- // `sv` claim does not match and derives local `pw:` keys from this value.
973
- sessionVersion: z.number().int().nonnegative().optional(),
974
- });
975
- // ---------------------------------------------------------------------------
976
- // 5. Storage + resolution shapes (additive)
977
- // ---------------------------------------------------------------------------
978
- // The CLOUD-stored policy for one space. This is the persisted, versioned form
979
- // of the policy lane: an ordered list of unified Rules plus a monotonic
980
- // `version` so the lane can promote/rollback independently of content (mirrors
981
- // `channelSchema.history`). `version` is bumped on every write; a write that
982
- // supplies a stale `version` is a lost-update and is rejected by the resolver.
983
- //
984
- // Provenance: these are the "cloud" rules. They are merged with any "file"
985
- // rules compiled from the space's `sf.jsonc` (which travel + roll back
986
- // WITH a content version) to produce the effective policy — see
987
- // `effectiveRuleSchema` / `effectivePolicySchema`.
988
- export const spacePolicySchema = z.object({
989
- // Ordered, additive Rules (first-match-wins at serving compile time). Same
990
- // unified Rule the runtime verifies; an absent `auth` is an anonymous
991
- // firewall rule (firewall ⊂ access).
992
- rules: z.array(runtimeRuleSchema).default([]),
993
- // Monotonic lane version. Starts at 1 on first write; every accepted write
994
- // increments it. Used for optimistic concurrency (lost-update rejection) and
995
- // for rollback to a prior lane revision.
996
- version: z.number().int().nonnegative().default(0),
997
- });
998
- // GET/PUT /v1/spaces/:id/access-rules wire schemas — the ordered, always-latest
999
- // access policy pair. Shared by the still-mounted /access route family and the
1000
- // detached /access-rules core adapter so activation has exactly one schema to
1001
- // retire, not two to reconcile.
1002
- export const spaceAccessRulesWriteSchema = z.object({
1003
- rules: z.array(runtimeRuleSchema).default([]),
1004
- expectedVersion: z.number().int().nonnegative().optional(),
1005
- });
1006
- // The lane read plus the plan-downgrade marker (access-plan X-23): plan-gated
1007
- // rules keep enforcing after a downgrade; this flag is the notice surface.
1008
- export const spaceAccessRulesReadSchema = spacePolicySchema.extend({
1009
- accessExceedsPlan: z.boolean().default(false),
1010
- });
1011
- // PUT/DELETE /v1/spaces/:id/quick-share wire schemas — the one active
1012
- // whole-space guest link (§5.2).
1013
- export const quickShareSchema = z.object({
1014
- kind: z.literal("quick_share"),
206
+ .max(ACCESS_GRANT_SCOPE_LIMIT),
207
+ });
208
+ export const personGrantWriteSchema = z.object({
209
+ scope: accessScopePathSchema,
210
+ role: accessRoleSchema,
211
+ });
212
+ export const spacePersonGrantSetWriteSchema = z.object({
213
+ grants: z.array(personGrantWriteSchema).min(1).max(ACCESS_GRANT_SCOPE_LIMIT),
214
+ });
215
+ export const accessShareLinkCreateSchema = z.object({
216
+ name: z.string().trim().min(1).max(200),
217
+ landingPath: accessScopePathSchema.default("/"),
218
+ resources: grantResourceSetSchema,
219
+ capabilities: linkCapabilitiesSchema.default(["page.view"]),
220
+ constraints: grantConstraintsSchema.default({}),
221
+ target: grantTargetSelectorSchema.default({ kind: "live" }),
222
+ });
223
+ export const accessShareLinkUpdateSchema = z.object({
224
+ name: z.string().trim().min(1).max(200).optional(),
225
+ landingPath: accessScopePathSchema.optional(),
226
+ resources: grantResourceSetSchema.optional(),
227
+ capabilities: linkCapabilitiesSchema.optional(),
228
+ constraints: grantConstraintsSchema.optional(),
229
+ target: grantTargetSelectorSchema.optional(),
230
+ });
231
+ export const canonicalAccessRequestCreateSchema = z.object({
232
+ email: z.string().email(),
233
+ scope: accessScopePathSchema,
234
+ requestedRole: accessRequestRoleSchema.default("viewer"),
235
+ message: z.string().max(280).optional(),
236
+ });
237
+ export const canonicalAccessRequestApproveSchema = z.object({
238
+ scope: accessScopePathSchema.optional(),
239
+ role: accessRoleSchema.optional(),
240
+ });
241
+ export const openHandoffSchema = z.object({
1015
242
  url: z.string().url(),
1016
- expiresAt: z.string().min(1),
1017
- outcome: z.enum(["created", "reused"]),
243
+ expiresAt: z.string().datetime(),
244
+ kind: z.enum(["management", "claim_preview", "person"]),
1018
245
  });
1019
- export const quickShareDeleteSchema = z.object({
1020
- outcome: z.enum(["deleted", "unchanged"]),
246
+ export const openHandoffCreateSchema = z.object({
247
+ landingPath: accessScopePathSchema.default("/"),
248
+ targetHost: z.string().trim().min(1).max(255).optional(),
1021
249
  });
1022
- // GET/PUT /v1/spaces/:id/enhancements wire schemas — whole-space page
1023
- // enhancements (analytics, tag manager, custom scripts) on/off. `active` is
1024
- // reserved and currently always empty (see the composition delta on
1025
- // spaces/enhancements-service.ts).
1026
- export const enhancementsSchema = z.object({
1027
- enabled: z.boolean(),
1028
- active: z.array(z.string()),
250
+ export const teamSpaceAccessDefaultSchema = z.object({
251
+ newSpaceGrantPreset: spaceAccessPresetSchema,
1029
252
  });
1030
- export const enhancementsWriteSchema = z.object({ enabled: z.boolean() });
1031
- // Where an effective rule came from. `platform` = composed by the cloud from
1032
- // platform state (plan window, abuse/ban/billing) and PREPENDED above every
1033
- // user-authored rule so a platform decision always wins first-match-wins; the
1034
- // user can NEVER author a `platform` rule. `file` = compiled from the space's
1035
- // `sf.jsonc` (timeless, rolls back with the content version). `cloud` =
1036
- // the stored `spacePolicySchema` lane (incident interventions, password walls
1037
- // set via the API/CLI, etc.). Provenance is surfaced so an operator reading the
1038
- // effective policy can tell which surface to edit to change a given rule.
1039
- export const ruleSourceSchema = z.enum(["platform", "file", "cloud"]);
1040
- // A provenance-annotated effective Rule: the unified `runtimeRuleSchema` plus
1041
- // the `source` it was contributed by and the `order` it occupies in the
1042
- // resolved, first-match-wins list. This is the READ shape returned by the
1043
- // effective-policy resolver — never stored. `order` is the 0-based index in the
1044
- // flattened effective list (file rules then cloud rules, or whatever order the
1045
- // resolver composes), letting a caller reconstruct match precedence.
1046
- export const effectiveRuleSchema = runtimeRuleSchema.extend({
1047
- source: ruleSourceSchema,
1048
- order: z.number().int().nonnegative(),
1049
- });
1050
- // The resolved effective policy for a space: the flattened, provenance-tagged
1051
- // rule list plus the `cloudVersion` (the `spacePolicySchema.version` the cloud
1052
- // rules were resolved from) so a reader can detect drift / pin a write against
1053
- // the revision it observed. `fileVersion` is the content version id the `file`
1054
- // rules were compiled from (null when the space has no compiled file rules).
1055
- export const effectivePolicySchema = z.object({
1056
- rules: z.array(effectiveRuleSchema).default([]),
1057
- // Space-wide verifier issuers. The control plane always emits the platform
1058
- // issuer here so identity-only public-space tokens verify without a rule.
1059
- issuers: z.array(accessKeySchema).default([]),
1060
- cloudVersion: z.number().int().nonnegative().default(0),
1061
- fileVersion: z.string().min(1).nullable().default(null),
1062
- // Serving-secret map (name -> value) the resolved rules reference by
1063
- // password-acquire `ref` = "secret:<name>". Carries the space password's
1064
- // bcrypt verifier hash, keyed so the runtime resolves it from
1065
- // `serving['secrets']` and `password_verify`s against it. Never contains
1066
- // plaintext the visitor types — only stored verifier hashes / shared secrets.
1067
- secrets: z.record(z.string().min(1), z.string().min(1)).default({}),
1068
- // Space session version (access-plan §3.1/§4.4): visitor tokens must carry a
1069
- // matching `sv` claim. Bumped by logout-all; also an input to the space-local
1070
- // `pw:` key derivation, so a bump invalidates every wall pass with zero new
1071
- // state.
1072
- sessionVersion: z.number().int().nonnegative().default(0),
253
+ export const teamSpaceAccessDefaultWriteSchema = z.object({
254
+ newSpaceGrantPreset: spaceAccessPresetSchema,
1073
255
  });
1074
256
  // ---------------------------------------------------------------------------
1075
- // 6. Simulate (access-plan §5.5, X-13)
257
+ // Unified visitor and collaboration tokens
1076
258
  // ---------------------------------------------------------------------------
1077
- // GET /v1/spaces/:id/access/simulate?host&path[&email&ip&country] the rule
1078
- // that would fire and why. Runs on the parity-pinned TS matcher, so simulate
1079
- // correctness is a corollary of the corpus.
1080
- export const accessSimulateResultSchema = z.object({
1081
- verdict: z.enum(["allow", "deny", "challenge"]),
1082
- // The first effective rule (with lane/source + order) whose match applies
1083
- // and whose effect resolves for the simulated identity; null = no rule
1084
- // gates the request (public).
1085
- matchedRule: effectiveRuleSchema.nullable().default(null),
1086
- // The matched rule's requiredGrants — which grants would satisfy it.
1087
- why: z.array(grantSchema).default([]),
1088
- // Human audience summary derived from requiredGrants ("Team members",
1089
- // "Anyone with an active share link", …).
1090
- whoWouldPass: z.array(z.string()).default([]),
259
+ export const ACCESS_TOKEN_KID = "spacefast-runtime-v1";
260
+ export const ACCESS_TOKEN_ALG = "EdDSA";
261
+ export const RUNTIME_JWKS_WELL_KNOWN_FILENAME = "spacefast-runtime-jwks.json";
262
+ export const accessTokenPayloadSchema = z.object({
263
+ sub: authorityReferenceSchema,
264
+ authorities: z.array(authorityReferenceSchema).min(1).max(16),
265
+ exp: z.number().int().nonnegative(),
266
+ nbf: z.number().int().nonnegative(),
267
+ iat: z.number().int().nonnegative(),
268
+ iss: z.string().min(1),
269
+ aud: z.string().min(1),
270
+ spaceId: z.string().min(1),
271
+ generation: z.number().int().nonnegative(),
272
+ emailVerified: z.boolean().default(false),
273
+ jti: z.string().min(1),
1091
274
  });