@spacefast/common 0.0.3 → 0.0.6

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 (159) hide show
  1. package/dist/agents/connect-targets.d.ts +133 -0
  2. package/dist/agents/connect-targets.js +224 -0
  3. package/dist/brand.d.ts +1 -0
  4. package/dist/brand.js +5 -0
  5. package/dist/config/domains.d.ts +12 -6
  6. package/dist/config/domains.js +30 -38
  7. package/dist/contracts/access.d.ts +829 -129
  8. package/dist/contracts/access.js +771 -148
  9. package/dist/contracts/activity.d.ts +5 -5
  10. package/dist/contracts/activity.js +28 -2
  11. package/dist/contracts/annotations.d.ts +3 -5
  12. package/dist/contracts/api-keys.d.ts +9 -10
  13. package/dist/contracts/api-keys.js +9 -6
  14. package/dist/contracts/archives.d.ts +22 -16
  15. package/dist/contracts/archives.js +11 -4
  16. package/dist/contracts/billing.d.ts +21 -30
  17. package/dist/contracts/builds.d.ts +21 -17
  18. package/dist/contracts/builds.js +12 -0
  19. package/dist/contracts/channels.d.ts +2 -2
  20. package/dist/contracts/common.d.ts +7 -5
  21. package/dist/contracts/common.js +4 -0
  22. package/dist/contracts/continuation.d.ts +63 -0
  23. package/dist/contracts/continuation.js +91 -0
  24. package/dist/contracts/countries.d.ts +2 -0
  25. package/dist/contracts/countries.js +261 -0
  26. package/dist/contracts/deployments.d.ts +34 -34
  27. package/dist/contracts/device-auth.d.ts +4 -11
  28. package/dist/contracts/device-auth.js +4 -0
  29. package/dist/contracts/docs.d.ts +119 -0
  30. package/dist/contracts/docs.js +51 -0
  31. package/dist/contracts/domains.d.ts +295 -253
  32. package/dist/contracts/domains.js +26 -4
  33. package/dist/contracts/enums.d.ts +17 -18
  34. package/dist/contracts/enums.js +25 -14
  35. package/dist/contracts/error-code-meta.d.ts +84 -0
  36. package/dist/contracts/error-code-meta.js +99 -10
  37. package/dist/contracts/error-codes.d.ts +1 -1
  38. package/dist/contracts/error-codes.js +17 -0
  39. package/dist/contracts/events.d.ts +13 -13
  40. package/dist/contracts/features.d.ts +93 -23
  41. package/dist/contracts/features.js +178 -13
  42. package/dist/contracts/git.d.ts +3 -9
  43. package/dist/contracts/ids.d.ts +5 -0
  44. package/dist/contracts/ids.js +5 -0
  45. package/dist/contracts/internal.d.ts +3 -3
  46. package/dist/contracts/mcp.d.ts +34 -36
  47. package/dist/contracts/me.d.ts +8 -8
  48. package/dist/contracts/me.js +1 -1
  49. package/dist/contracts/oauth-resources.d.ts +19 -8
  50. package/dist/contracts/oauth-resources.js +44 -13
  51. package/dist/contracts/operations.d.ts +2 -2
  52. package/dist/contracts/plan-policy.d.ts +4 -4
  53. package/dist/contracts/platform.d.ts +3 -105
  54. package/dist/contracts/platform.js +0 -50
  55. package/dist/contracts/push-new.d.ts +4 -4
  56. package/dist/contracts/quotas.d.ts +2 -0
  57. package/dist/contracts/quotas.js +8 -0
  58. package/dist/contracts/repository-connections.d.ts +1 -3
  59. package/dist/contracts/resources.d.ts +112 -28
  60. package/dist/contracts/resources.js +26 -2
  61. package/dist/contracts/routes.d.ts +0 -2
  62. package/dist/contracts/runtime-api.d.ts +365 -43
  63. package/dist/contracts/runtime-api.js +275 -6
  64. package/dist/contracts/sites.d.ts +45 -28
  65. package/dist/contracts/sites.js +21 -0
  66. package/dist/contracts/space-config.d.ts +201 -2
  67. package/dist/contracts/space-config.js +135 -5
  68. package/dist/contracts/spaces.d.ts +423 -162
  69. package/dist/contracts/spaces.js +94 -8
  70. package/dist/contracts/superadmin-emails.d.ts +7 -5
  71. package/dist/contracts/superadmin-emails.js +8 -0
  72. package/dist/contracts/superadmin-queues.d.ts +32 -32
  73. package/dist/contracts/superadmin-spaces.d.ts +418 -24
  74. package/dist/contracts/superadmin-spaces.js +24 -0
  75. package/dist/contracts/superadmin-tenants.d.ts +8 -8
  76. package/dist/contracts/superadmin.d.ts +17 -104
  77. package/dist/contracts/superadmin.js +6 -14
  78. package/dist/contracts/tags.d.ts +42 -41
  79. package/dist/contracts/tags.js +2 -1
  80. package/dist/contracts/teams.d.ts +40 -23
  81. package/dist/contracts/teams.js +20 -1
  82. package/dist/contracts/theme-json.d.ts +30 -0
  83. package/dist/contracts/theme-json.js +48 -0
  84. package/dist/contracts/transfers.d.ts +9 -9
  85. package/dist/contracts/variables.d.ts +14 -16
  86. package/dist/contracts/variables.js +0 -5
  87. package/dist/contracts/webhooks.d.ts +6 -4
  88. package/dist/contracts/webhooks.js +1 -1
  89. package/dist/contracts/zero.d.ts +13 -18
  90. package/dist/docs/agent-prose.d.ts +43 -3
  91. package/dist/docs/agent-prose.js +153 -10
  92. package/dist/docs/agent-setup.js +48 -15
  93. package/dist/docs/agent-solutions.d.ts +5 -5
  94. package/dist/docs/agent-solutions.js +2 -2
  95. package/dist/docs/catalog.d.ts +650 -0
  96. package/dist/docs/catalog.js +758 -0
  97. package/dist/docs/error-docs.d.ts +0 -2
  98. package/dist/docs/error-docs.js +89 -35
  99. package/dist/docs/index-build.d.ts +17 -0
  100. package/dist/docs/index-build.js +24 -0
  101. package/dist/docs/search.d.ts +54 -0
  102. package/dist/docs/search.js +96 -0
  103. package/dist/docs/skill-distribution.d.ts +5 -3
  104. package/dist/docs/skill-distribution.js +8 -8
  105. package/dist/utils/access-match.d.ts +39 -0
  106. package/dist/utils/access-match.js +296 -0
  107. package/dist/utils/auth-redirect.d.ts +3 -1
  108. package/dist/utils/auth-redirect.js +21 -1
  109. package/dist/utils/build-settings.d.ts +6 -0
  110. package/dist/utils/build-settings.js +429 -46
  111. package/dist/utils/claim-token.d.ts +8 -0
  112. package/dist/utils/claim-token.js +23 -2
  113. package/dist/utils/content-type.d.ts +1 -0
  114. package/dist/utils/content-type.js +1 -1
  115. package/dist/utils/credential-policy.d.ts +54 -0
  116. package/dist/utils/{access-policy.js → credential-policy.js} +28 -2
  117. package/dist/utils/gate-theme.d.ts +61 -0
  118. package/dist/utils/gate-theme.js +217 -0
  119. package/dist/utils/git-repository.d.ts +0 -1
  120. package/dist/utils/git-repository.js +0 -3
  121. package/dist/utils/id-hints.d.ts +8 -0
  122. package/dist/utils/id-hints.js +61 -0
  123. package/dist/utils/local-space-state.d.ts +91 -0
  124. package/dist/utils/local-space-state.js +251 -0
  125. package/dist/utils/oauth-signed-query.d.ts +8 -0
  126. package/dist/utils/oauth-signed-query.js +26 -0
  127. package/dist/utils/publish-detection.d.ts +0 -8
  128. package/dist/utils/publish-detection.js +0 -93
  129. package/dist/utils/publish-policy.d.ts +6 -5
  130. package/dist/utils/publish-policy.js +71 -7
  131. package/dist/utils/query-keys.d.ts +17 -6
  132. package/dist/utils/query-keys.js +27 -4
  133. package/dist/utils/runtime-paths.d.ts +0 -1
  134. package/dist/utils/runtime-paths.js +0 -1
  135. package/dist/utils/runtime-upload-batch.d.ts +18 -0
  136. package/dist/utils/runtime-upload-batch.js +90 -0
  137. package/dist/utils/runtime-upload.d.ts +2 -0
  138. package/dist/utils/runtime-upload.js +5 -0
  139. package/dist/utils/space-config.d.ts +4 -0
  140. package/dist/utils/space-config.js +10 -1
  141. package/dist/utils/space-theme.d.ts +2 -0
  142. package/dist/utils/space-theme.js +9 -0
  143. package/dist/utils/static-runtime-policy.d.ts +1 -1
  144. package/dist/utils/static-runtime-policy.js +47 -15
  145. package/dist/utils/upload-session.d.ts +1 -1
  146. package/dist/utils/wpcom-auth-redirects.d.ts +32 -2
  147. package/dist/utils/wpcom-auth-redirects.js +26 -10
  148. package/dist/vocabulary.d.ts +130 -30
  149. package/dist/vocabulary.js +77 -36
  150. package/package.json +16 -1
  151. package/dist/contracts/account.d.ts +0 -28
  152. package/dist/contracts/account.js +0 -54
  153. package/dist/contracts/intercom.d.ts +0 -6
  154. package/dist/contracts/intercom.js +0 -5
  155. package/dist/contracts/telemetry.d.ts +0 -2
  156. package/dist/contracts/telemetry.js +0 -1
  157. package/dist/utils/access-policy.d.ts +0 -39
  158. package/dist/utils/error-display.d.ts +0 -7
  159. package/dist/utils/error-display.js +0 -42
@@ -1,13 +1,15 @@
1
1
  import { z } from "zod";
2
- // Unified access contracts (internal-docs/stattic-model-plan.html "Section I ·
3
- // access" + internal-docs/access-management-plan.md).
2
+ // Unified access contracts (internal-docs/access-plan.html the only access
3
+ // document).
4
4
  //
5
- // Unified access contracts (the one access & policy lane). The headline
6
- // simplification: **firewall ⊂ access** — there is exactly one Rule schema, and
7
- // an anonymous firewall rule is simply a Rule whose `auth` is absent. The
8
- // runtime enforces a verifiable subset (password secret-compare + token
9
- // Ed25519/grant-intersection + anonymous firewall); the cloud resolves identity
10
- // and mints signed tokens. Field names are the spec contract — do not rename.
5
+ // The one access & policy lane. The headline simplification: **firewall ⊂
6
+ // access** — there is exactly one Rule schema, and an anonymous firewall rule
7
+ // is simply a Rule whose `auth` is absent. There is exactly ONE satisfaction
8
+ // test: token grants requiredGrants (X-32); `auth.acquire` only configures
9
+ // how the challenge page lets a visitor obtain a token. The runtime verifies
10
+ // fully offline; the cloud resolves identity and mints signed tokens (plus
11
+ // exactly one non-cloud minter: the runtime's space-local `pw:` mint). Field
12
+ // names are the spec contract — do not rename.
11
13
  // ---------------------------------------------------------------------------
12
14
  // 1. Grants
13
15
  // ---------------------------------------------------------------------------
@@ -19,35 +21,41 @@ import { z } from "zod";
19
21
  // `requiredGrants` so a rule can name a class of identities:
20
22
  //
21
23
  // email:alice@acme.com a single verified email
22
- // email:*@acme.com any verified email on a domain (glob)
24
+ // email:*@acme.com any verified email on a domain (glob — this IS
25
+ // the "anyone at a domain" audience; there is no
26
+ // separate email-domain namespace)
23
27
  // team:team_123:member top-level team membership at a role
24
28
  // sub-team:sub_123:member enterprise group membership inside a team
25
29
  // user:<handle> a specific platform person
26
- // svc:<tokenId> a service token (CI, agents)
27
- // ext:{connectionId}:<tag> an owner-defined external grant
30
+ // space:{spaceId}:{level} a leveled space grant (SPACE_LEVELS)
31
+ // link:lnk_x an active share link (structural revocation)
32
+ // invite:sin_x an accepted invite (structural revocation)
33
+ // svc:stk_x a service token (CI, agents; structural)
34
+ // sso:acn_x an OIDC connection identity
35
+ // ext:{connectionId}:<tag> an owner-defined external (BYO issuer) grant
36
+ // pw:{ruleId} a satisfied password wall (runtime-local mint —
37
+ // the ONLY namespace the space-local key signs)
28
38
  //
29
- // Tokens signed by an external (BYO) key may only carry grants namespaced to
30
- // that key (see `accessKeySchema.grantNamespaces`); everything else is dropped
31
- // before intersection.
39
+ // Namespaces exist iff a minter exists. Tokens signed by an external (BYO) key
40
+ // may only carry grants namespaced to that key (see
41
+ // `accessKeySchema.grantNamespaces`); everything else is dropped before
42
+ // intersection.
32
43
  export const grantSchema = z.string().min(1).max(255);
33
- // Known grant namespace prefixes. Useful for validation/UX; not exhaustive of
34
- // every future namespace, but covers the documented vocabulary.
44
+ // Known grant namespace prefixes final vocabulary (access-plan §1).
35
45
  export const GRANT_NAMESPACES = [
36
46
  "email:",
37
47
  "team:",
38
48
  "sub-team:",
39
49
  "user:",
40
- "svc:",
41
- "ext:",
42
- // Mint-time-only namespaces (sources the cloud resolves into the above, plus
43
- // transient grants the runtime mints itself).
44
- "email-domain:",
45
50
  "space:",
46
51
  "link:",
52
+ "invite:",
53
+ "svc:",
47
54
  "sso:",
48
- "pw:", // minted by a form-transport password challenge
55
+ "ext:",
56
+ "pw:",
49
57
  ];
50
- const GRANT_PATTERN = /^(email|email-domain|team|sub-team|user|svc|ext|space|link|sso|pw):.+$/;
58
+ const GRANT_PATTERN = /^(email|team|sub-team|user|space|link|invite|svc|sso|ext|pw):.+$/;
51
59
  // Validator for grant globs like "email:*@acme.com", "team:team_123:member",
52
60
  // "sub-team:sub_123:member", "user:<h>", "svc:…", "ext:{conn}:…". Returns true when the grant string is a
53
61
  // well-formed `namespace:value`. This is advisory (the runtime intersects
@@ -61,6 +69,12 @@ export function isWellFormedGrant(grant) {
61
69
  export const requiredGrantSchema = grantSchema.refine(isWellFormedGrant, {
62
70
  message: "grant must be namespaced, e.g. email:*@acme.com, team:team_123:member, sub-team:sub_123:member, user:<h>, svc:…, ext:{conn}:…",
63
71
  });
72
+ // The ONE space-level vocabulary (access-plan X-38): consumed by
73
+ // `space:{id}:{level}` grants, Cast's capability table, invitee levels, and the
74
+ // future editor role. No parallel level enum may exist anywhere. v1 surfaces
75
+ // mint only `viewer` and `commenter`.
76
+ export const SPACE_LEVELS = ["viewer", "commenter", "editor", "admin"];
77
+ export const spaceLevelSchema = z.enum(SPACE_LEVELS);
64
78
  // ---------------------------------------------------------------------------
65
79
  // 2. Issuer registry
66
80
  // ---------------------------------------------------------------------------
@@ -74,29 +88,227 @@ export const accessKeySchema = z.object({
74
88
  publicKey: z.string().min(1).describe("base64url-encoded raw Ed25519 public key."),
75
89
  // Grants in a token signed by this key are DROPPED unless prefixed by one of
76
90
  // these. Platform keys list the platform prefixes
77
- // (["team:","sub-team:","user:","email:","email-domain:","space:","link:","svc:","sso:"]);
91
+ // (["team:","sub-team:","user:","email:","space:","link:","svc:","sso:"]);
78
92
  // external keys list exactly ["ext:{connectionId}:"], bounding a leaked key's
79
93
  // blast radius to the rules the owner pointed at it.
80
94
  grantNamespaces: z.array(z.string().min(1)).min(1),
81
95
  });
82
96
  // ---------------------------------------------------------------------------
97
+ // 2b. Identity connections (access-plan §4.4/§6.2/§6.3, X-18/X-19)
98
+ // ---------------------------------------------------------------------------
99
+ // A team-level identity connection (`acn_` row): either a company OIDC
100
+ // relying-party config (§6.2 — the authorize endpoint runs the code flow and
101
+ // mints the visitor token; no Spacefast account is created) or an external
102
+ // BYO issuer (§6.3 — the shop holds the private key and mints its own
103
+ // Ed25519 tokens; Spacefast is not in the login path).
104
+ export const ACCESS_CONNECTION_TYPES = ["oidc", "external"];
105
+ export const accessConnectionTypeSchema = z.enum(ACCESS_CONNECTION_TYPES);
106
+ // Session TTL defaults (§4.4): 8h for company SSO (re-auth is a transparent
107
+ // bounce while the IdP session lives), 30min for external issuers (the short
108
+ // TTL bounds entitlement drift — a cancelled subscriber survives at most one
109
+ // TTL).
110
+ export const OIDC_CONNECTION_DEFAULT_TTL_SECONDS = 8 * 60 * 60; // 28800
111
+ export const EXTERNAL_CONNECTION_DEFAULT_TTL_SECONDS = 30 * 60; // 1800
112
+ // Hard ceiling on an EXTERNAL-issued token's exp (§6.3: exp ≤ 30min). The
113
+ // connection's sessionTtlSeconds may be shorter, never longer.
114
+ export const EXTERNAL_TOKEN_MAX_TTL_SECONDS = 30 * 60;
115
+ // The grant a company-SSO visitor carries (§6.2): `sso:{connectionId}`.
116
+ export function ssoGrant(connectionId) {
117
+ return `sso:${connectionId}`;
118
+ }
119
+ // The grant namespace an external connection's keys are bound to (§6.3):
120
+ // tokens signed by a shop key are dropped unless prefixed `ext:{acn}:` — a
121
+ // leaked shop key can forge exactly the rules the owner pointed at it, never
122
+ // team:/user: grants (corpus-pinned).
123
+ export function externalGrantNamespace(connectionId) {
124
+ return `ext:${connectionId}:`;
125
+ }
126
+ // An owner-defined external grant (§6.3): `ext:{acn}:{tag}`, e.g.
127
+ // `ext:acn_x:wc-subscriber`.
128
+ export function externalGrant(connectionId, tag) {
129
+ return `ext:${connectionId}:${tag}`;
130
+ }
131
+ // STORED form of a connection login acquire (`loginAcquireSchema.url`): the
132
+ // compiler stores the symbolic `connection:{acn}` reference and the resolver
133
+ // rewrites it to the live URL on every policy resolution (the OIDC leg URL or
134
+ // the shop's authorizeUrl with the CURRENT space/sv/host baked in). Nothing
135
+ // stored ever carries a stale sessionVersion; a revoked connection's acquire
136
+ // resolves to nothing (the login block disappears — fail closed).
137
+ export function connectionAcquireRef(connectionId) {
138
+ return `connection:${connectionId}`;
139
+ }
140
+ export function parseConnectionAcquireRef(url) {
141
+ if (!url.startsWith("connection:")) {
142
+ return null;
143
+ }
144
+ const id = url.slice("connection:".length);
145
+ return id.length > 0 && !id.includes("/") && !id.includes(":") ? id : null;
146
+ }
147
+ // The connection ids a rule references — through `sso:{acn}` / `ext:{acn}:…`
148
+ // requiredGrants or symbolic `connection:{acn}` acquire refs. The resolver
149
+ // embeds these connections' keys/acquire legs at hydration time.
150
+ export function referencedConnectionIds(input) {
151
+ const ids = new Set();
152
+ for (const grant of input.requiredGrants ?? []) {
153
+ if (grant.startsWith("sso:")) {
154
+ const id = grant.slice("sso:".length);
155
+ if (id && !id.includes(":")) {
156
+ ids.add(id);
157
+ }
158
+ }
159
+ else if (grant.startsWith("ext:")) {
160
+ const id = grant.slice("ext:".length).split(":")[0];
161
+ if (id) {
162
+ ids.add(id);
163
+ }
164
+ }
165
+ }
166
+ for (const acquire of input.acquire ?? []) {
167
+ if (acquire.type === "login" && acquire.url) {
168
+ const id = parseConnectionAcquireRef(acquire.url);
169
+ if (id) {
170
+ ids.add(id);
171
+ }
172
+ }
173
+ }
174
+ return [...ids];
175
+ }
176
+ // A raw Ed25519 public key is 32 bytes → 43 base64url chars, no padding.
177
+ export const externalConnectionPublicKeySchema = z.object({
178
+ kid: z.string().min(1).max(64),
179
+ publicKey: z
180
+ .string()
181
+ .regex(/^[A-Za-z0-9_-]{43}$/, "publicKey must be a base64url raw 32-byte Ed25519 public key"),
182
+ });
183
+ const guestEmailDomainsSchema = z
184
+ .array(z
185
+ .string()
186
+ .min(3)
187
+ .max(255)
188
+ .regex(/^[a-z0-9.-]+\.[a-z]{2,}$/i, "must be a bare domain, e.g. acme.com"))
189
+ .max(20);
190
+ // The connection projected onto the wire — the OIDC client secret NEVER
191
+ // appears (masked after write, §4.4); `clientSecretSet` says one is stored.
192
+ export const accessConnectionSchema = z.object({
193
+ id: z.string().min(1),
194
+ teamId: z.string().min(1),
195
+ type: accessConnectionTypeSchema,
196
+ name: z.string().min(1),
197
+ // OIDC half (null for external connections).
198
+ issuer: z.string().min(1).nullable().default(null),
199
+ clientId: z.string().min(1).nullable().default(null),
200
+ clientSecretSet: z.boolean().default(false),
201
+ // External half (null for oidc connections). `authorizeUrl` is HTTPS-pinned
202
+ // at create and admin-only to mutate.
203
+ authorizeUrl: z.string().min(1).nullable().default(null),
204
+ publicKeys: z.array(externalConnectionPublicKeySchema).default([]),
205
+ sessionTtlSeconds: z.number().int().positive(),
206
+ // Verified-email domains auto-granted team-viewer at mint (§6.2); null = none.
207
+ guestEmailDomains: z.array(z.string().min(1)).nullable().default(null),
208
+ createdAt: z.string().min(1),
209
+ revokedAt: z.string().min(1).nullable().default(null),
210
+ active: z.boolean(),
211
+ });
212
+ export const oidcConnectionCreateSchema = z.object({
213
+ type: z.literal("oidc"),
214
+ name: z.string().min(1).max(200),
215
+ // The IdP issuer URL (HTTPS): discovery runs against
216
+ // `{issuer}/.well-known/openid-configuration`.
217
+ issuer: z.string().min(1).max(1024),
218
+ clientId: z.string().min(1).max(512),
219
+ clientSecret: z.string().min(1).max(2048),
220
+ sessionTtlSeconds: z
221
+ .number()
222
+ .int()
223
+ .min(60)
224
+ .max(24 * 60 * 60)
225
+ .optional(),
226
+ guestEmailDomains: guestEmailDomainsSchema.optional(),
227
+ });
228
+ export const externalConnectionCreateSchema = z.object({
229
+ type: z.literal("external"),
230
+ name: z.string().min(1).max(200),
231
+ // The shop's authorize endpoint (HTTPS-pinned): the challenge page's login
232
+ // link forwards straight here for single-audience external rules.
233
+ authorizeUrl: z.string().min(1).max(2048),
234
+ publicKeys: z.array(externalConnectionPublicKeySchema).min(1).max(5),
235
+ sessionTtlSeconds: z.number().int().min(60).max(EXTERNAL_TOKEN_MAX_TTL_SECONDS).optional(),
236
+ guestEmailDomains: guestEmailDomainsSchema.optional(),
237
+ });
238
+ export const accessConnectionCreateSchema = z.discriminatedUnion("type", [
239
+ oidcConnectionCreateSchema,
240
+ externalConnectionCreateSchema,
241
+ ]);
242
+ // PATCH shape — fields are validated against the connection's type at the
243
+ // route (`authorizeUrl`/`publicKeys` are external-only; `issuer`/`clientId`/
244
+ // `clientSecret` are oidc-only). `publicKeys` REPLACES the key set (push-key
245
+ // rotation: push the new key alongside the old, flip signing, drop the old).
246
+ export const accessConnectionUpdateSchema = z.object({
247
+ name: z.string().min(1).max(200).optional(),
248
+ issuer: z.string().min(1).max(1024).optional(),
249
+ clientId: z.string().min(1).max(512).optional(),
250
+ clientSecret: z.string().min(1).max(2048).optional(),
251
+ authorizeUrl: z.string().min(1).max(2048).optional(),
252
+ publicKeys: z.array(externalConnectionPublicKeySchema).min(1).max(5).optional(),
253
+ sessionTtlSeconds: z
254
+ .number()
255
+ .int()
256
+ .min(60)
257
+ .max(24 * 60 * 60)
258
+ .optional(),
259
+ guestEmailDomains: guestEmailDomainsSchema.nullable().optional(),
260
+ });
261
+ // DELETE response: revocation is structural — rules referencing the
262
+ // connection lose those grants in the immediate recompile; rules left with an
263
+ // empty audience are disabled (removed) and listed so the owner knows exactly
264
+ // what stopped enforcing via that connection.
265
+ export const accessConnectionRevokeResultSchema = z.object({
266
+ connection: accessConnectionSchema,
267
+ disabledRules: z
268
+ .array(z.object({ spaceId: z.string().min(1), ruleId: z.string().min(1) }))
269
+ .default([]),
270
+ });
271
+ // ---------------------------------------------------------------------------
83
272
  // 3. The unified runtime Rule (firewall = a rule with no auth)
84
273
  // ---------------------------------------------------------------------------
85
- // The `auth` object: pass when EITHER the password OR the token leg is
86
- // satisfied. Folds the old basicAuth / sso / login / composite requirement
87
- // types into two legs. An absent `auth` makes the rule an anonymous firewall
88
- // rule (effect applies on match, no credential needed) — this is the sense in
89
- // which **firewall access**.
90
- export const passwordAuthSchema = z.object({
91
- // A secret reference (e.g. "secret:site_pw"), never plaintext. The runtime
92
- // resolves it from serving secrets and compares.
274
+ // The `auth` object (access-plan X-32): there is exactly ONE satisfaction
275
+ // test token grants `requiredGrants`. `acquire` entries only configure how
276
+ // the challenge page lets an unsatisfied visitor OBTAIN a token:
277
+ //
278
+ // password the challenge page renders a password form; a valid POST mints
279
+ // the standard visitor token RUNTIME-LOCALLY (space HS256 key,
280
+ // `pw:{ruleId}` grant) into the one `spacefast_access` cookie.
281
+ // `basic` transport is the stateless HTTP 401 variant — valid
282
+ // Basic credentials satisfy `pw:{ruleId}` per-request (browsers
283
+ // replay the Authorization header; no cookie needed).
284
+ // login → the challenge page renders a sign-in link into the cloud
285
+ // authorize chain (or a BYO issuer's authorizeUrl).
286
+ //
287
+ // Mixed audiences are just multiple acquire entries on one rule — the chooser
288
+ // is the challenge page rendering every acquire block, never a new surface.
289
+ export const passwordAcquireSchema = z.object({
290
+ type: z.literal("password"),
291
+ // A secret reference (e.g. "secret:space-password"), never plaintext. The
292
+ // runtime resolves it from serving secrets and `password_verify`s.
93
293
  ref: z.string().min(1),
94
- // `basic` = stateless HTTP Basic 401 challenge. `form` = renders the password
95
- // wall, and on success mints a short-lived `pw:` grant cookie.
96
294
  transport: z.enum(["basic", "form"]),
97
295
  username: z.string().min(1).optional(),
98
296
  });
99
- export const tokenAuthSchema = z.object({
297
+ export const loginAcquireSchema = z.object({
298
+ type: z.literal("login"),
299
+ // Interactive login surface the challenge page links to (the cloud authorize
300
+ // endpoint, or a BYO connection's authorizeUrl). Hydrated by the compiler;
301
+ // the runtime appends `return=` as a plain same-origin path.
302
+ url: z.string().min(1),
303
+ // Display label for the challenge-page button (e.g. "Continue with
304
+ // Spacefast", "Sign in with Acme Shop").
305
+ label: z.string().min(1).optional(),
306
+ });
307
+ export const acquireSchema = z.discriminatedUnion("type", [
308
+ passwordAcquireSchema,
309
+ loginAcquireSchema,
310
+ ]);
311
+ export const ruleAuthSchema = z.object({
100
312
  // ANY-OF: the visitor's verified grants must intersect this set. Entries may
101
313
  // be globs (e.g. "email:*@acme.com").
102
314
  requiredGrants: z.array(grantSchema).min(1),
@@ -104,26 +316,19 @@ export const tokenAuthSchema = z.object({
104
316
  // compiler fills in the applicable platform/registered keys. Tokens signed by
105
317
  // a key contribute only grants within that key's `grantNamespaces`.
106
318
  issuers: z.array(accessKeySchema).optional(),
107
- // Interactive login surface the runtime 302-redirects an unsatisfied browser
108
- // visitor to (with an opaque `return=` param). Absent = no interactive login
109
- // (service-token-only rule) and the runtime denies a browser rather than loop.
110
- loginUrl: z.string().min(1).optional(),
111
- // Cookie name the runtime reads the visitor token from (default
112
- // `stattic_access`); the Authorization Bearer header always takes precedence.
113
- cookieName: z.string().min(1).optional(),
114
- });
115
- export const ruleAuthSchema = z
116
- .object({
117
- password: passwordAuthSchema.optional(),
118
- token: tokenAuthSchema.optional(),
119
- })
120
- .refine((a) => a.password !== undefined || a.token !== undefined, {
121
- message: "auth must specify at least one of `password` or `token`",
319
+ // How the challenge page lets a visitor get a token. Absent = no interactive
320
+ // acquisition (service-token-only rule): the runtime denies a browser rather
321
+ // than loop.
322
+ acquire: z.array(acquireSchema).optional(),
122
323
  });
123
324
  // Match conditions AND together (host AND path AND channel AND ipCidrs AND …).
124
325
  // All fields optional; an empty match matches everything in scope.
125
326
  export const ruleMatchSchema = z.object({
126
327
  host: z.string().min(1).optional(),
328
+ hostPattern: z.string().min(1).optional(),
329
+ // `_headers` absolute host matcher template: ":branch.example.com" keeps
330
+ // placeholders single-label while "*" keeps the existing host wildcard shape.
331
+ hostTemplate: z.string().min(1).optional(),
127
332
  // Glob path pattern: "/docs/**" | "/docs/*" | "/x.html".
128
333
  pathPattern: z.string().min(1).optional(),
129
334
  // Serving context channel name (e.g. "live", "staging").
@@ -137,46 +342,68 @@ export const ruleMatchSchema = z.object({
137
342
  // Single request-header matcher: name + expected value.
138
343
  header: z.object({ name: z.string().min(1), value: z.string() }).optional(),
139
344
  });
345
+ // Managed-block owners (access-plan X-34). Block ownership is THIS schema
346
+ // field, never a reasonCode convention: `managedBy` is stripped/rejected from
347
+ // all user input, so a raw rule can never claim or clobber a managed block.
348
+ // Cloud-lane block order: operator/incident rules first, then `firewall`, then
349
+ // `sharing`/`file_share`/`cast_reviewer`, then `team_default` last.
350
+ export const MANAGED_BY_VALUES = [
351
+ "sharing",
352
+ "firewall",
353
+ "file_share",
354
+ "cast_reviewer",
355
+ "team_default",
356
+ ];
357
+ export const managedBySchema = z.enum(MANAGED_BY_VALUES);
140
358
  // The one Rule. `match` (AND) selects requests; `effect` is what happens on
141
359
  // match; `auth` (absent = anonymous firewall) gates `challenge`/`allow`.
142
360
  export const runtimeRuleSchema = z.object({
361
+ // Stable rule id. Required wherever a grant references the rule
362
+ // (`pw:{ruleId}`); the compiler assigns ids to managed rules.
363
+ id: z.string().min(1).optional(),
143
364
  match: ruleMatchSchema.default({}),
144
365
  effect: z.enum(["allow", "deny", "challenge"]),
145
366
  // Absent = anonymous firewall rule (no credential required). Present = pass
146
- // when the password OR token leg is satisfied.
367
+ // when token grants requiredGrants (the one satisfaction test).
147
368
  auth: ruleAuthSchema.optional(),
369
+ // Which product surface owns this rule (managed blocks). Never accepted from
370
+ // user input — `replaceManagedRules` is the only writer.
371
+ managedBy: managedBySchema.optional(),
372
+ // Unix seconds. TTL'd interventions (`sf block --ttl`): the runtime SKIPS an
373
+ // expired rule at match time (one timestamp compare, no recompile needed);
374
+ // the compiler GCs expired rules on the next write.
375
+ expiresAt: z.number().int().nonnegative().optional(),
148
376
  // Surfaced on a `deny` (X-Spacefast-Reason header) and the rendered platform
149
- // page so an operator/visitor can tell which rule fired and why.
377
+ // page so an operator/visitor can tell which rule fired and why. Diagnostic
378
+ // only — never load-bearing for ownership or semantics.
150
379
  reasonCode: z.string().min(1).optional(),
151
380
  message: z.string().min(1).optional(),
152
381
  });
153
382
  // ---------------------------------------------------------------------------
154
383
  // 3b. Visitor token (the cloud "mint" half)
155
384
  // ---------------------------------------------------------------------------
156
- // The default cookie the runtime reads a visitor token from when a rule's token
157
- // leg names no explicit `cookieName` (see access-rules.php
158
- // `_stattic_unified_token_from_request`). The authorize endpoint sets the
159
- // minted token under this name so the very next request to the protected host
160
- // carries it.
161
- export const ACCESS_TOKEN_COOKIE_NAME = "stattic_access";
162
385
  // The header `kid`/`alg` the platform signs visitor tokens with. These match
163
- // the runtime JWKS key (`stattic-runtime-v1` / EdDSA) so the runtime's
164
- // JWKS-by-kid verify (access-rules.php) accepts a token the cloud minted. Do
386
+ // the runtime JWKS key (`spacefast-runtime-v1` / EdDSA) so the runtime's
387
+ // JWKS-by-kid verify (shared/jwt.php) accepts a token the cloud minted. Do
165
388
  // NOT introduce a second key system — these constants name the existing one.
166
- export const ACCESS_TOKEN_KID = "stattic-runtime-v1";
389
+ export const ACCESS_TOKEN_KID = "spacefast-runtime-v1";
167
390
  export const ACCESS_TOKEN_ALG = "EdDSA";
168
- // The DEDICATED `kid` Cast access tokens are signed under (CAST-NATIVE-PLAN
169
- // decision D-1). It is published in the SAME JWKS as the runtime key, but is
170
- // distinct so the runtime's JWKS-by-kid verify (which only accepts
171
- // `ACCESS_TOKEN_KID`) refuses a Cast token outright, decoupling Cast key
172
- // rotation from the runtime. Cast selects the verifying key by this `kid`.
173
- export const CAST_TOKEN_KID = "spacefast-cast-v1";
391
+ // The published JWKS path (served under /.well-known/ by the control plane and
392
+ // fetched by the runtime + Cast).
393
+ export const RUNTIME_JWKS_WELL_KNOWN_FILENAME = "spacefast-runtime-jwks.json";
394
+ // The `kid` the runtime's SPACE-LOCAL HS256 mint signs under (access-plan
395
+ // §3.1). The key derives from the space's password secret + sessionVersion and
396
+ // may only sign `pw:` grants — the same namespace filter that bounds BYO keys
397
+ // bounds this one. Never published in any JWKS; verification re-derives the
398
+ // key from serving secrets.
399
+ export const LOCAL_PW_TOKEN_KID = "spacefast-local-pw-v1";
174
400
  // The claim set carried by a minted visitor token. `grants` are concrete
175
401
  // (no `*`), `email:`/`user:`/`team:<teamId>:<role>`/`sub-team:<subTeamId>:<role>` namespaced so they survive
176
402
  // the platform issuer's `grantNamespaces` filter at the runtime. `aud`, when
177
403
  // present, lowercases-binds the token to a single serving host.
178
404
  export const accessTokenPayloadSchema = z.object({
179
- // Subject: the authenticated platform user id the token was minted for.
405
+ // Subject: the authenticated platform user id the token was minted for
406
+ // (or "invite:sin_x" / "link:lnk_x" / "pw:anon" for account-less audiences).
180
407
  sub: z.string().min(1),
181
408
  // Concrete (non-glob) grants. The runtime intersects these with a rule's
182
409
  // `requiredGrants`; the visitor passes when the intersection is non-empty.
@@ -189,19 +416,25 @@ export const accessTokenPayloadSchema = z.object({
189
416
  // Optional host binding (lowercased) — when set the token is only accepted on
190
417
  // that serving host.
191
418
  aud: z.string().min(1).optional(),
419
+ // Space session version (access-plan §3.1): the runtime rejects a visitor
420
+ // token whose `sv` mismatches `policy.sessionVersion`. Logout-all is an `sv`
421
+ // bump — structural, TTL-independent revocation. Absent = 0.
422
+ sv: z.number().int().nonnegative().optional(),
423
+ // Single-use id for authorize-chain callback handoffs (access-plan X-29):
424
+ // the runtime callback consumes `jti` in its replay cache, so a token lifted
425
+ // from browser history is dead after first use. Share tokens carry no `jti`
426
+ // (the share URL IS the credential; revocation is structural).
427
+ jti: z.string().min(1).optional(),
192
428
  });
193
- // Authorize request: the bounded authenticated-team-member mint path. The
194
- // caller is already an authenticated session; the body only tunes the mint.
195
- // `setCookie` (default true) asks the endpoint to also set the access cookie so
196
- // the next navigation to the protected host is authorized without the client
197
- // re-attaching the token. `aud` optionally binds the token to one host.
429
+ // Authorize request for `POST /v1/spaces/:id/access/authorize` surviving for
430
+ // exactly one caller: the dashboard preview-token mint (iframe runtime
431
+ // callback). It never sets cookies; only the runtime callback sets the visitor
432
+ // cookie. `aud` binds the token to the previewed host.
198
433
  export const authorizeRequestSchema = z.object({
199
- setCookie: z.boolean().default(true),
200
434
  aud: z.string().min(1).optional(),
201
435
  });
202
436
  // Authorize response: the signed compact JWT plus its decoded claims (so a
203
- // caller can show/inspect the grants without re-parsing) and whether the access
204
- // cookie was set on the response.
437
+ // caller can show/inspect the grants without re-parsing).
205
438
  export const authorizeResponseSchema = z.object({
206
439
  // The signed compact EdDSA JWT (`header.claims.signature`, base64url).
207
440
  token: z.string().min(1),
@@ -209,100 +442,458 @@ export const authorizeResponseSchema = z.object({
209
442
  payload: accessTokenPayloadSchema,
210
443
  // Unix seconds the token expires at (convenience mirror of `payload.exp`).
211
444
  expiresAt: z.number().int().nonnegative(),
212
- // The cookie name the token was set under, or null when `setCookie` was false.
213
- cookieName: z.string().min(1).nullable().default(null),
445
+ });
446
+ // ---------------------------------------------------------------------------
447
+ // 3b'. Access events (access-plan §5.6b, X-37)
448
+ // ---------------------------------------------------------------------------
449
+ // The access event is a first-class contract from day one: the runtime emits
450
+ // one per enforced decision into the per-hostname analytics ingestion; the
451
+ // Views panel is a reader of an already-flowing stream. `grantsHash` is a
452
+ // sha256 of the sorted verified grant list (never the grants themselves).
453
+ export const accessEventSchema = z.object({
454
+ sub: z.string().min(1).nullable().default(null),
455
+ grantsHash: z.string().min(1).nullable().default(null),
456
+ ruleId: z.string().min(1).nullable().default(null),
457
+ effect: z.enum(["allow", "deny", "challenge"]),
458
+ reasonCode: z.string().min(1).nullable().default(null),
459
+ host: z.string().min(1),
460
+ path: z.string().min(1),
461
+ // Unix seconds.
462
+ ts: z.number().int().nonnegative(),
214
463
  });
215
464
  // ---------------------------------------------------------------------------
216
465
  // 3c. Mint-time grant builders (the cloud "resolve identity → grants" half)
217
466
  // ---------------------------------------------------------------------------
218
- // The concrete grant a space invitee carries once they accept: a token with
219
- // `space:{spaceId}:viewer` intersects the `invited`-audience rule the compiler
220
- // emits (`requiredGrants: ["space:{spaceId}:viewer"]` + team grants, §6.3).
221
- // The platform issuer lists the `space:` namespace, so the runtime keeps it.
222
- export function spaceViewerGrant(spaceId) {
223
- return `space:${spaceId}:viewer`;
224
- }
225
- // The leveled space grant an invited reviewer carries (CAST-NATIVE-PLAN D-2/
226
- // D-4): `space:{spaceId}:{level}` where level ∈ viewer|commenter|editor. The
227
- // `space:` namespace is on the platform issuer (resolve.ts `platformIssuer`), so
228
- // the runtime keeps it; Cast reads the level off the token to key its
229
- // capability table. `spaceViewerGrant` is the `viewer` special case kept for the
230
- // accepted-invite path; this is the general leveled form the reviewer people
231
- // layer reconciles and the Cast mint carries.
467
+ // The leveled space grant (access-plan X-38): `space:{spaceId}:{level}` where
468
+ // level ∈ SPACE_LEVELS. The `space:` namespace is on the platform issuer, so
469
+ // the runtime keeps it; Cast keys its capability table off this grant; the
470
+ // accepted-invite path mints the `viewer` level.
232
471
  export function spaceLevelGrant(spaceId, level) {
233
472
  return `space:${spaceId}:${level}`;
234
473
  }
235
474
  // The concrete grant a share-link holder carries: a token with `link:{linkId}`
236
- // intersects the `shareLinks`-audience rule whose `requiredGrants` the compiler
237
- // fills from the ACTIVE link rows (§6.4). Revoking the link recompiles it out
475
+ // intersects the `sharing:`-block rule whose `requiredGrants` the compiler
476
+ // fills from the ACTIVE link rows (§5.2). Revoking the link recompiles it out
238
477
  // and the token goes inert. The platform issuer lists the `link:` namespace.
239
478
  export function shareLinkGrant(linkId) {
240
479
  return `link:${linkId}`;
241
480
  }
481
+ // The concrete grant an invited viewer carries: `invite:{inviteId}` compiled
482
+ // per active invite (§5.3); revocation is structural.
483
+ export function inviteGrant(inviteId) {
484
+ return `invite:${inviteId}`;
485
+ }
486
+ // The concrete grant a service token carries: `svc:{tokenId}` compiled
487
+ // wherever the token's `actsAs` satisfies (§5.5); revocation is structural.
488
+ export function serviceTokenGrant(tokenId) {
489
+ return `svc:${tokenId}`;
490
+ }
242
491
  // ---------------------------------------------------------------------------
243
- // 3d. Invite + share-link mint surface (request/response contracts)
492
+ // 3d. Share links + the sharing projection (access-plan §5.1/§5.2, X-33)
244
493
  // ---------------------------------------------------------------------------
245
- //
246
- // ADDITIVE / NOT-YET-WIRED: the request/response shapes for the invite and
247
- // share-link minting endpoints described in access-management-plan.md §6.3/§6.4
248
- // and §9. The persistence (the `spaceInvites` / `shareLinks` tables, magic-token
249
- // hashing, accept/create/revoke routes, the recompile-on-revoke step, and the
250
- // request-access loop) needs DB migrations + email and is specified in
251
- // internal-docs/invite-minting-plan.md these schemas are the contract that
252
- // plan's endpoints will validate against, safe to land first.
253
- // `POST /v1/spaces/:id/invites` body (§6.3). Invites a single email as a space
254
- // viewer. The email is lowercased + NFC-normalized server-side before storage.
255
- export const inviteCreateRequestSchema = z.object({
256
- email: z.string().email(),
257
- // Optional invite-link validity in days (default 7d at the route).
258
- expiresInDays: z.number().int().positive().max(365).optional(),
259
- // Optional note shown in the invite email.
260
- message: z.string().max(300).optional(),
261
- });
262
- // A pending/accepted/revoked invite as surfaced to owners. The magic token is
263
- // NEVER returned — only its lifecycle. `id` is `sin_…`.
494
+ // Share tokens are re-signed on demand from the link row (§5.2): the JWT is
495
+ // never stored, only the row. `exp` = the link's expiry capped at this ceiling
496
+ // (and re-copying re-mints, so a live link's URL never goes stale).
497
+ export const SHARE_LINK_TOKEN_MAX_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days
498
+ // A share link row projected onto the wire (§4.4). `path` is "/" for a
499
+ // space-wide link; any other value scopes the link's grant to that subtree
500
+ // (compile-time: the grant only ever appears in rules matching the path).
501
+ // `lastUsedAt`/`useCount` are the operational-observability columns (X-28);
502
+ // the runtime-side `?sf_share=` trade increments them offline, so they read 0
503
+ // until that ingestion lands.
504
+ export const shareLinkSchema = z.object({
505
+ id: z.string().min(1),
506
+ spaceId: z.string().min(1),
507
+ label: z.string().min(1).nullable().default(null),
508
+ path: z.string().min(1),
509
+ createdBy: z.string().min(1).nullable().default(null),
510
+ // Unix seconds; null = the link never expires (tokens still cap at 30d and
511
+ // re-mint from the row on copy).
512
+ expiresAt: z.number().int().nonnegative().nullable().default(null),
513
+ createdAt: z.string().min(1).describe("ISO 8601 timestamp."),
514
+ lastUsedAt: z.string().min(1).nullable().default(null),
515
+ useCount: z.number().int().nonnegative().default(0),
516
+ // false once revoked or past expiry — the grant is no longer compiled.
517
+ active: z.boolean(),
518
+ });
519
+ export const shareLinkCreateSchema = z.object({
520
+ // A plain absolute path ("/" = the whole space).
521
+ path: z.string().min(1).max(1024).default("/"),
522
+ // Unix seconds. Omitted = no expiry (structural revocation still applies).
523
+ expiresAt: z.number().int().positive().optional(),
524
+ label: z.string().min(1).max(200).optional(),
525
+ });
526
+ // Returned on create and on the explicit re-copy intent ONLY — list reads never
527
+ // carry the token-bearing URL (§5.2: share-URL responses are no-store).
528
+ export const shareLinkWithUrlSchema = shareLinkSchema.extend({
529
+ url: z.string().min(1).describe("https://<host><path>?sf_share=<token>"),
530
+ });
531
+ // The Share modal's General-access selector (§5.1). The mode is DERIVED from
532
+ // the `sharing:` managed block — never stored separately — so "who can see
533
+ // this" has exactly one home.
534
+ export const GENERAL_ACCESS_MODES = ["public", "password", "link", "people"];
535
+ export const generalAccessModeSchema = z.enum(GENERAL_ACCESS_MODES);
536
+ // A connection audience member on the People-only selector state (§6.2/§6.3):
537
+ // "your company SSO" (oidc — optionally narrowed to a verified email domain)
538
+ // or "Acme Shop subscribers" (external — the owner-defined grant tag).
539
+ export const sharingConnectionAudienceSchema = z.object({
540
+ connectionId: z.string().min(1),
541
+ // External connections: the grant tag ("wc-subscriber" → ext:{acn}:{tag}).
542
+ // Required for external, rejected for oidc.
543
+ tag: z
544
+ .string()
545
+ .min(1)
546
+ .max(100)
547
+ .regex(/^[A-Za-z0-9._-]+$/)
548
+ .optional(),
549
+ // OIDC connections: narrow the audience to a verified email domain — the
550
+ // rule compiles `email:*@{domain}` instead of `sso:{acn}` (the SSO mint
551
+ // carries the verified `email:` grant). Rejected for external.
552
+ emailDomain: z
553
+ .string()
554
+ .min(3)
555
+ .max(255)
556
+ .regex(/^[a-z0-9.-]+\.[a-z]{2,}$/i)
557
+ .optional(),
558
+ });
559
+ export const generalAccessWriteSchema = z.object({
560
+ mode: generalAccessModeSchema,
561
+ // Required when switching to "password" for the first time; optional when a
562
+ // stored password already exists (it is kept).
563
+ password: z.string().min(1).max(256).optional(),
564
+ // People-mode connection audiences (§6.4): compiled INTO the one sharing
565
+ // general rule (mixed audiences = one rule, multiple acquires — the chooser
566
+ // is the challenge page, never a new surface). Omitted = keep the current
567
+ // audiences; [] = remove them.
568
+ audiences: z.array(sharingConnectionAudienceSchema).max(20).optional(),
569
+ });
570
+ // A connection audience projected back on the sharing read (derived from the
571
+ // compiled block — never stored separately, X-33).
572
+ export const sharingAudienceSchema = z.object({
573
+ connectionId: z.string().min(1),
574
+ type: accessConnectionTypeSchema,
575
+ // Connection display name (null when the connection row is gone).
576
+ name: z.string().min(1).nullable().default(null),
577
+ tag: z.string().min(1).nullable().default(null),
578
+ emailDomain: z.string().min(1).nullable().default(null),
579
+ // The compiled requiredGrants entry this audience contributes.
580
+ grant: grantSchema,
581
+ });
582
+ // A row in the Share modal's People list. Team members are badged; invitees
583
+ // (§5.3) carry their invite id + status so the modal can revoke per-row and
584
+ // badge pending/accepted/expired.
585
+ export const sharingPersonSchema = z.object({
586
+ kind: z.enum(["team_member", "invitee"]),
587
+ userId: z.string().min(1).nullable().default(null),
588
+ email: z.string().min(1).nullable().default(null),
589
+ name: z.string().min(1).nullable().default(null),
590
+ // Team management role for members (owner|admin|member); null for invitees.
591
+ teamRole: z.string().min(1).nullable().default(null),
592
+ // Space level for invitees (viewer in v1); null for team members.
593
+ level: spaceLevelSchema.nullable().default(null),
594
+ // The sin_ invite id for invitees (per-row revoke); null for team members.
595
+ inviteId: z.string().min(1).nullable().default(null),
596
+ // Invite lifecycle for invitees ("expired" = the accept LINK lapsed
597
+ // unaccepted; access-grant compilation is structural, not TTL'd); null for
598
+ // team members. Revoked invites never appear in the People list.
599
+ status: z.enum(["pending", "accepted", "expired"]).nullable().default(null),
600
+ // Operational observability (X-28): per-invitee "last viewed"; null until
601
+ // the Views ingestion feeds it.
602
+ lastViewedAt: z.string().min(1).nullable().default(null),
603
+ });
604
+ // ---------------------------------------------------------------------------
605
+ // 3d'. Invites (access-plan §5.3) + access requests (§5.4)
606
+ // ---------------------------------------------------------------------------
607
+ // Accept-link validity (§4.4): how long the emailed magic link mints tokens.
608
+ // Access itself is structural (the invite grant stays compiled until revoke);
609
+ // an expired link only means "re-mail me a new one".
610
+ export const INVITE_ACCEPT_LINK_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
611
+ // The visitor token an accept mints (§5.3): 30 days, re-entry by re-clicking
612
+ // the emailed link (X-8: no guest sessions, the inbox is the credential).
613
+ export const INVITE_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days
614
+ // Invite/request personal messages (X-25): ≤280 chars, plain text only —
615
+ // stored as typed, HTML-escaped at every render (email + pages), never
616
+ // linkified, rendered visually subordinate to the platform frame.
617
+ export const ACCESS_MESSAGE_MAX_LENGTH = 280;
618
+ export const INVITE_STATUSES = ["pending", "accepted", "expired", "revoked"];
619
+ export const inviteStatusSchema = z.enum(INVITE_STATUSES);
620
+ // A space invite projected onto the wire (§4.4). The accept token is NEVER
621
+ // stored or returned — only its hash is at rest, and the token travels solely
622
+ // in the invitee's email (possession of the inbox IS the credential). `email`
623
+ // is null once revoked (X-25: invitee addresses are PII of non-users and are
624
+ // purged on revoke; the row survives for audit).
264
625
  export const spaceInviteSchema = z.object({
265
626
  id: z.string().min(1),
266
627
  spaceId: z.string().min(1),
267
- email: z.string().email(),
268
- status: z.enum(["pending", "accepted", "revoked", "expired"]),
269
- // The viewer grant this invite mints on acceptance — surfaced so the owner can
270
- // see exactly what it grants (`space:{spaceId}:viewer`).
271
- grant: z.string().min(1),
628
+ email: z.string().min(1).nullable().default(null),
272
629
  invitedBy: z.string().min(1).nullable().default(null),
273
- expiresAt: z.string().min(1).nullable().default(null),
630
+ message: z.string().nullable().default(null),
631
+ status: inviteStatusSchema,
632
+ // Unix seconds the accept LINK stops minting (re-mail extends it).
633
+ expiresAt: z.number().int().nonnegative(),
274
634
  acceptedAt: z.string().min(1).nullable().default(null),
275
- revokedAt: z.string().min(1).nullable().default(null),
276
635
  createdAt: z.string().min(1),
636
+ // Operational observability (X-28): honest null until the Views ingestion.
637
+ lastViewedAt: z.string().min(1).nullable().default(null),
277
638
  });
278
- // `POST /v1/spaces/:id/share-links` body (§6.4). Creates an "anyone with the
279
- // link" grant scoped to a path prefix, with an optional expiry + label.
280
- export const shareLinkCreateRequestSchema = z.object({
281
- // "/" = the whole space. Must be an absolute, backslash-free path.
282
- pathPrefix: z
283
- .string()
284
- .regex(/^\/[^\\]*$/)
285
- .default("/"),
286
- // Absent = a non-expiring link.
287
- expiresAt: z.string().min(1).optional(),
288
- label: z.string().max(120).optional(),
289
- });
290
- // A share link as surfaced to owners. `url` (the token-bearing URL) is returned
291
- // ONLY on the create response (the runtime trades `?stattic_share=` for a
292
- // cookie once); list/read responses omit it and show only the lifecycle. `id`
293
- // is `lnk_…`; `grant` is `link:{id}`.
294
- export const shareLinkSchema = z.object({
639
+ export const spaceInviteCreateSchema = z.object({
640
+ email: z.string().min(3).max(320),
641
+ message: z.string().max(ACCESS_MESSAGE_MAX_LENGTH).optional(),
642
+ });
643
+ export const ACCESS_REQUEST_STATUSES = ["pending", "approved", "denied"];
644
+ export const accessRequestStatusSchema = z.enum(ACCESS_REQUEST_STATUSES);
645
+ // An access request (§5.4): a signed-in-but-unsatisfied visitor asking to be
646
+ // let in from the 403 page. Coalesced on (spaceId, email) while pending.
647
+ export const accessRequestSchema = z.object({
295
648
  id: z.string().min(1),
296
649
  spaceId: z.string().min(1),
297
- pathPrefix: z.string().min(1),
298
- label: z.string().nullable().default(null),
299
- grant: z.string().min(1),
300
- // The token-bearing URL — present only on create; null on list/read.
301
- url: z.string().min(1).nullable().default(null),
302
- createdBy: z.string().min(1).nullable().default(null),
303
- expiresAt: z.string().min(1).nullable().default(null),
304
- revokedAt: z.string().min(1).nullable().default(null),
650
+ email: z.string().min(1),
651
+ userId: z.string().min(1).nullable().default(null),
652
+ host: z.string().min(1),
653
+ path: z.string().min(1),
654
+ message: z.string().nullable().default(null),
655
+ status: accessRequestStatusSchema,
656
+ resolvedBy: z.string().min(1).nullable().default(null),
657
+ resolvedAt: z.string().min(1).nullable().default(null),
658
+ createdAt: z.string().min(1),
659
+ });
660
+ // POST /v1/access/requests — the 403-page form's target. Identity (email +
661
+ // userId) comes from the SESSION, never the body: the authorize chain
662
+ // guarantees requesters are identified (anonymous → 401).
663
+ export const accessRequestCreateSchema = z.object({
664
+ space: z.string().min(1),
665
+ host: z.string().min(1).max(255),
666
+ path: z.string().max(1024).default("/"),
667
+ nonce: z.string().min(1).max(2048).optional(),
668
+ message: z.string().max(ACCESS_MESSAGE_MAX_LENGTH).optional(),
669
+ });
670
+ // POST …/requests/:id/approve — the chooser (§5.4): "invite" reuses the §5.3
671
+ // magic-link invite; "team" is the Better-Auth team invitation.
672
+ export const accessRequestApproveSchema = z.object({
673
+ as: z.enum(["invite", "team"]).default("invite"),
674
+ });
675
+ // File-mode sharing (§5.1 files-mode merge): the dashboard-side file-share
676
+ // rule compiler is dead; file shares are a server-side intent surface owning
677
+ // the `file_share` managed block. A record is keyed by the file/folder PATH;
678
+ // the server expands the serving-path aliases (exact, directory index,
679
+ // extensionless .html variant) at compile time.
680
+ export const FILE_SHARE_AUDIENCES = ["public", "people"];
681
+ export const fileShareAudienceSchema = z.enum(FILE_SHARE_AUDIENCES);
682
+ // The projected file-share record (read shape on the sharing projection).
683
+ export const fileShareSchema = z.object({
684
+ // Canonical absolute file path ("/report/deck.pdf") or folder path ("/report/").
685
+ path: z.string().min(1),
686
+ audience: fileShareAudienceSchema,
687
+ // The email: grants admitted when audience = "people"; empty for "public".
688
+ grants: z.array(grantSchema).default([]),
689
+ // The compiled serving-path alias patterns the record covers (diagnostic).
690
+ patterns: z.array(z.string().min(1)).default([]),
691
+ });
692
+ // PUT /v1/spaces/:id/file-shares — upsert the record for one path. `grants`
693
+ // accepts raw emails or `email:` grants; the server normalizes + validates.
694
+ export const fileShareWriteSchema = z.object({
695
+ path: z.string().min(1).max(1024),
696
+ audience: fileShareAudienceSchema,
697
+ grants: z.array(z.string().min(1)).max(100).optional(),
698
+ });
699
+ // A pending access request row on the sharing projection (§5.4). `host`/`path`
700
+ // say where the requester was headed so the admin has context.
701
+ export const sharingRequestSchema = z.object({
702
+ id: z.string().min(1),
703
+ email: z.string().min(1),
704
+ message: z.string().nullable().default(null),
705
+ createdAt: z.string().min(1),
706
+ host: z.string().min(1).nullable().default(null),
707
+ path: z.string().min(1).nullable().default(null),
708
+ });
709
+ // ---------------------------------------------------------------------------
710
+ // 3d''. Service tokens (access-plan §4.4/§5.5 — machine principals)
711
+ // ---------------------------------------------------------------------------
712
+ // Service tokens are the machine-principal leg of the IAM table (§5.5):
713
+ // `stk_` rows, Bearer-only, shown ONCE on create, `svc:{id}` grants compiled
714
+ // wherever the token's `actsAs` satisfies, structural revocation (X-7 —
715
+ // revoke recompiles the grant out; the credential's TTL is irrelevant).
716
+ //
717
+ // The secret IS a platform-signed visitor JWT ({sub: "svc:stk_x", grants:
718
+ // ["svc:stk_x"], exp: expiresAt | now+10y}) — the runtime's Bearer path
719
+ // already verifies visitor JWTs before the cookie (§3.2), so CI/agents fetch
720
+ // protected artifacts with `Authorization: Bearer <token>` and ZERO runtime
721
+ // work. Only the token's sha256 is stored (lastUsedAt attribution/display);
722
+ // the plaintext never persists.
723
+ //
724
+ // `sv` posture (documented, accepted): a space-scoped token pins the space's
725
+ // sessionVersion at mint; a team-wide token carries sv 0 (the absent-sv
726
+ // default). A space "logout-all" (sv bump) therefore ALSO rotates service
727
+ // access to that space — re-create the token. Arguably correct: logout-all
728
+ // means "everyone out", and service tokens are visitors too.
729
+ export const SERVICE_TOKEN_DEFAULT_TTL_SECONDS = 10 * 365 * 24 * 60 * 60; // 10 years
730
+ // The audience classes a service token can act as. v1 has exactly one:
731
+ // "viewers" — the token satisfies every managed rule that admits the
732
+ // team-viewer audience (the sharing block's general rule in `link`/`people`
733
+ // modes and file-share `people` rules). It does NOT satisfy a password wall
734
+ // (`pw:` is password-only, like team members) or raw rules that don't name
735
+ // `svc:` grants.
736
+ export const SERVICE_TOKEN_AUDIENCES = ["viewers"];
737
+ export const serviceTokenAudienceSchema = z.enum(SERVICE_TOKEN_AUDIENCES);
738
+ export const serviceTokenActsAsSchema = z.object({
739
+ audiences: z.array(serviceTokenAudienceSchema).min(1).default(["viewers"]),
740
+ });
741
+ // A service token projected onto the wire — NEVER carries the secret; the
742
+ // signed JWT appears exactly once, on the create response.
743
+ export const serviceTokenSchema = z.object({
744
+ id: z.string().min(1),
745
+ teamId: z.string().min(1),
746
+ // null = team-wide (the grant compiles into every team space).
747
+ spaceId: z.string().min(1).nullable().default(null),
748
+ name: z.string().min(1),
749
+ actsAs: serviceTokenActsAsSchema,
750
+ // Unix seconds; null = the 10-year default ceiling applies.
751
+ expiresAt: z.number().int().nonnegative().nullable().default(null),
752
+ // Operational observability (X-28, mirroring share links): fed by the
753
+ // runtime access-event ingestion; honest null until that reader lands.
754
+ lastUsedAt: z.string().min(1).nullable().default(null),
305
755
  createdAt: z.string().min(1),
756
+ // false once revoked or past expiry — the grant is no longer compiled.
757
+ active: z.boolean(),
758
+ });
759
+ export const serviceTokenCreateSchema = z.object({
760
+ name: z.string().min(1).max(200),
761
+ // Scope the grant to one space; omitted = team-wide.
762
+ spaceId: z.string().min(1).optional(),
763
+ // Unix seconds; omitted = the 10-year default. Revocation stays structural
764
+ // either way — expiry is a hygiene ceiling, not the revocation mechanism.
765
+ expiresAt: z.number().int().positive().optional(),
766
+ actsAs: serviceTokenActsAsSchema.optional(),
767
+ });
768
+ // Returned ONLY on create (no-store): the signed Bearer JWT, shown once.
769
+ export const serviceTokenWithSecretSchema = serviceTokenSchema.extend({
770
+ token: z.string().min(1),
771
+ });
772
+ // ---------------------------------------------------------------------------
773
+ // 3d'''. The firewall card document (access-plan §5.6, X-15)
774
+ // ---------------------------------------------------------------------------
775
+ // Firewall is rows, not a subsystem (X-15): this document is the SIMPLE
776
+ // altitude — a server-side intent surface that compiles to plain anonymous
777
+ // (deny) or team-viewer challenge ("must sign in") rules in the `firewall`
778
+ // managed block, ordered before `sharing` so a block always beats a link.
779
+ // The projection is DERIVED from the block (X-33-style); rules in the block
780
+ // the projection doesn't recognize flip `custom` and the card sends the user
781
+ // to the raw-rules editor — never to a feature request.
782
+ export const FIREWALL_EFFECTS = ["deny", "challenge"];
783
+ export const firewallEffectSchema = z.enum(FIREWALL_EFFECTS);
784
+ // Country section: `block` denies/challenges the listed countries; `allow`
785
+ // admits ONLY the listed countries (the compiler enumerates the complement —
786
+ // rule `match.country` is an exact ISO code with no negation, so allow-mode
787
+ // is expressed as one rule per non-listed country).
788
+ export const firewallCountriesSchema = z.object({
789
+ mode: z.enum(["block", "allow"]),
790
+ // ISO 3166-1 alpha-2, uppercased server-side.
791
+ list: z.array(z.string().length(2)).min(1).max(250),
792
+ // deny = blocked outright; challenge = "visitors from these countries must
793
+ // sign in" (the team-viewer audience per §5.6).
794
+ effect: firewallEffectSchema.default("deny"),
795
+ });
796
+ export const firewallIpEntrySchema = z.object({
797
+ // CIDR ("203.0.113.0/24", v4 or v6) or a bare IP (exact match).
798
+ cidr: z.string().min(1).max(64),
799
+ effect: firewallEffectSchema.default("deny"),
800
+ });
801
+ export const firewallBotEntrySchema = z.object({
802
+ // Case-insensitive user-agent substring (the runtime's `agent` matcher).
803
+ agent: z.string().min(1).max(200),
804
+ effect: firewallEffectSchema.default("deny"),
805
+ });
806
+ // PUT /v1/spaces/:id/access/firewall — replaces the recognized firewall-card
807
+ // rules; unrecognized (raw-authored… impossible, managedBy is server-only —
808
+ // i.e. legacy/operator-written) firewall-block rules are PRESERVED and keep
809
+ // the projection's `custom` flag on.
810
+ export const firewallWriteSchema = z.object({
811
+ countries: firewallCountriesSchema.nullable().default(null),
812
+ ipCidrs: z.array(firewallIpEntrySchema).max(50).default([]),
813
+ bots: z.array(firewallBotEntrySchema).max(50).default([]),
814
+ });
815
+ // GET /v1/spaces/:id/access/firewall — the projection derived from the block.
816
+ export const spaceFirewallSchema = z.object({
817
+ countries: firewallCountriesSchema.nullable().default(null),
818
+ ipCidrs: z.array(firewallIpEntrySchema).default([]),
819
+ bots: z.array(firewallBotEntrySchema).default([]),
820
+ // True when the firewall block holds rules this document can't express —
821
+ // the card links to the raw-rules editor instead of lying.
822
+ custom: z.boolean().default(false),
823
+ });
824
+ // ---------------------------------------------------------------------------
825
+ // 3d''''. Team preview-lock (access-plan §4.2/X-24 flagship default)
826
+ // ---------------------------------------------------------------------------
827
+ // "Non-live channels are team-only", configured once for a whole team. The
828
+ // rule `match.channel` is an exact name with no negation, so NOT-live is
829
+ // expressed structurally inside the team_default block (evaluated LAST in the
830
+ // cloud lane): an anonymous allow pinned to the live channel, then a
831
+ // catch-all team-viewer challenge. Live requests terminate on the allow
832
+ // (identical to the no-rule-matches default); anything on another channel
833
+ // falls through to the lock. Because the block is last, every explicit space
834
+ // rule still wins first-match before either rule.
835
+ //
836
+ // These rules are canonical vocabulary (single-homed here): the dashboard
837
+ // toggle and the CLI post exactly these rows through the EXISTING
838
+ // PUT /v1/teams/:id/access/defaults — no second compiler exists.
839
+ export const TEAM_PREVIEW_LOCK_LIVE_RULE_ID = "team-preview-lock:live";
840
+ export const TEAM_PREVIEW_LOCK_RULE_ID = "team-preview-lock";
841
+ export function teamPreviewLockRules(teamId) {
842
+ return [
843
+ {
844
+ id: TEAM_PREVIEW_LOCK_LIVE_RULE_ID,
845
+ match: { channel: "live" },
846
+ effect: "allow",
847
+ reasonCode: "team_preview_lock",
848
+ },
849
+ {
850
+ id: TEAM_PREVIEW_LOCK_RULE_ID,
851
+ match: {},
852
+ effect: "challenge",
853
+ auth: { requiredGrants: [`team:${teamId}:viewer`] },
854
+ reasonCode: "team_preview_lock",
855
+ message: "Non-live channels on this team are team-only.",
856
+ },
857
+ ];
858
+ }
859
+ export function isTeamPreviewLockRule(rule) {
860
+ return rule.id === TEAM_PREVIEW_LOCK_LIVE_RULE_ID || rule.id === TEAM_PREVIEW_LOCK_RULE_ID;
861
+ }
862
+ // Whether a team-defaults rule list carries the preview lock.
863
+ export function teamPreviewLockActive(rules) {
864
+ return rules.some((rule) => rule.id === TEAM_PREVIEW_LOCK_RULE_ID);
865
+ }
866
+ // Toggle helper: returns the defaults list with the lock applied/removed,
867
+ // preserving every other default rule (lock rules ride LAST so other
868
+ // defaults keep first-match precedence within the block).
869
+ export function withTeamPreviewLock(rules, teamId, enabled) {
870
+ const kept = rules.filter((rule) => !isTeamPreviewLockRule(rule));
871
+ return enabled ? [...kept, ...teamPreviewLockRules(teamId)] : kept;
872
+ }
873
+ // GET /v1/spaces/:id/sharing — the server-owned projection (X-33): the
874
+ // dashboard renders this and posts intents; it never interprets or compiles
875
+ // rules.
876
+ export const spaceSharingSchema = z.object({
877
+ generalAccess: z.object({
878
+ mode: generalAccessModeSchema,
879
+ // Whether a stored space password exists (the verifier hash, never the
880
+ // password) — lets the modal say "keep existing password".
881
+ hasPassword: z.boolean(),
882
+ }),
883
+ people: z.array(sharingPersonSchema).default([]),
884
+ // Connection audiences on the People-only state (§6.2/§6.3), derived from
885
+ // the compiled sharing block.
886
+ audiences: z.array(sharingAudienceSchema).default([]),
887
+ links: z.array(shareLinkSchema).default([]),
888
+ // Per-path file shares (the `file_share` managed block projected back).
889
+ fileShares: z.array(fileShareSchema).default([]),
890
+ requests: z.array(sharingRequestSchema).default([]),
891
+ // True when rules OUTSIDE the sharing block gate the space (unmanaged cloud
892
+ // rules or file-lane rules): the modal shows the "custom access rules
893
+ // active" chip linking to the access settings.
894
+ customRulesActive: z.boolean(),
895
+ // Plan-downgrade marker (X-23), mirrored from the access lane read.
896
+ accessExceedsPlan: z.boolean().default(false),
306
897
  });
307
898
  // ---------------------------------------------------------------------------
308
899
  // 4. Lanes / nouns (additive types only — not wired)
@@ -335,6 +926,12 @@ export const servingConfigSchema = z.object({
335
926
  // the firewall+access lane (own version + rollback, independent of content).
336
927
  export const policyDocSchema = z.object({
337
928
  rules: z.array(runtimeRuleSchema).default([]),
929
+ // Space-wide verifier issuers used by first-party access surfaces
930
+ // (callback/me/token), including public spaces that have no auth rules.
931
+ issuers: z.array(accessKeySchema).optional(),
932
+ // Runtime-serving session version. The enforcer rejects visitor tokens whose
933
+ // `sv` claim does not match and derives local `pw:` keys from this value.
934
+ sessionVersion: z.number().int().nonnegative().optional(),
338
935
  });
339
936
  // ---------------------------------------------------------------------------
340
937
  // 5. Storage + resolution shapes (additive)
@@ -385,12 +982,38 @@ export const effectiveRuleSchema = runtimeRuleSchema.extend({
385
982
  // rules were compiled from (null when the space has no compiled file rules).
386
983
  export const effectivePolicySchema = z.object({
387
984
  rules: z.array(effectiveRuleSchema).default([]),
985
+ // Space-wide verifier issuers. The control plane always emits the platform
986
+ // issuer here so identity-only public-space tokens verify without a rule.
987
+ issuers: z.array(accessKeySchema).default([]),
388
988
  cloudVersion: z.number().int().nonnegative().default(0),
389
989
  fileVersion: z.string().min(1).nullable().default(null),
390
990
  // Serving-secret map (name -> value) the resolved rules reference by
391
- // `auth.password.ref` = "secret:<name>". Today this carries the space
392
- // password's bcrypt verifier hash, keyed so the runtime resolves it from
991
+ // password-acquire `ref` = "secret:<name>". Carries the space password's
992
+ // bcrypt verifier hash, keyed so the runtime resolves it from
393
993
  // `serving['secrets']` and `password_verify`s against it. Never contains
394
994
  // plaintext the visitor types — only stored verifier hashes / shared secrets.
395
995
  secrets: z.record(z.string().min(1), z.string().min(1)).default({}),
996
+ // Space session version (access-plan §3.1/§4.4): visitor tokens must carry a
997
+ // matching `sv` claim. Bumped by logout-all; also an input to the space-local
998
+ // `pw:` key derivation, so a bump invalidates every wall pass with zero new
999
+ // state.
1000
+ sessionVersion: z.number().int().nonnegative().default(0),
1001
+ });
1002
+ // ---------------------------------------------------------------------------
1003
+ // 6. Simulate (access-plan §5.5, X-13)
1004
+ // ---------------------------------------------------------------------------
1005
+ // GET /v1/spaces/:id/access/simulate?host&path[&email&ip&country] → the rule
1006
+ // that would fire and why. Runs on the parity-pinned TS matcher, so simulate
1007
+ // correctness is a corollary of the corpus.
1008
+ export const accessSimulateResultSchema = z.object({
1009
+ verdict: z.enum(["allow", "deny", "challenge"]),
1010
+ // The first effective rule (with lane/source + order) whose match applies
1011
+ // and whose effect resolves for the simulated identity; null = no rule
1012
+ // gates the request (public).
1013
+ matchedRule: effectiveRuleSchema.nullable().default(null),
1014
+ // The matched rule's requiredGrants — which grants would satisfy it.
1015
+ why: z.array(grantSchema).default([]),
1016
+ // Human audience summary derived from requiredGrants ("Team members",
1017
+ // "Anyone with an active share link", …).
1018
+ whoWouldPass: z.array(z.string()).default([]),
396
1019
  });