@spacefast/common 0.0.11 → 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 (160) hide show
  1. package/dist/agents/connect-targets.d.ts +4 -4
  2. package/dist/agents/connect-targets.js +3 -3
  3. package/dist/agents/private-key-oauth.d.ts +37 -0
  4. package/dist/agents/private-key-oauth.js +158 -0
  5. package/dist/config/domains.js +1 -1
  6. package/dist/config/index.d.ts +1 -0
  7. package/dist/config/index.js +1 -0
  8. package/dist/config/postgres-budget.d.ts +126 -0
  9. package/dist/config/postgres-budget.js +135 -0
  10. package/dist/config/postgres-budget.json +50 -0
  11. package/dist/contracts/access.d.ts +386 -1183
  12. package/dist/contracts/access.js +240 -1018
  13. package/dist/contracts/activity.d.ts +1 -1
  14. package/dist/contracts/activity.js +11 -19
  15. package/dist/contracts/api-keys.d.ts +69 -45
  16. package/dist/contracts/api-keys.js +3 -0
  17. package/dist/contracts/archives.d.ts +9 -10
  18. package/dist/contracts/archives.js +4 -6
  19. package/dist/contracts/builds.d.ts +8 -8
  20. package/dist/contracts/builds.js +28 -48
  21. package/dist/contracts/channels.js +1 -2
  22. package/dist/contracts/comments.d.ts +102 -93
  23. package/dist/contracts/comments.js +121 -63
  24. package/dist/contracts/common.d.ts +4 -3
  25. package/dist/contracts/common.js +2 -0
  26. package/dist/contracts/countries.js +2 -7
  27. package/dist/contracts/data-source-routes.d.ts +20 -0
  28. package/dist/contracts/data-source-routes.js +15 -0
  29. package/dist/contracts/deployments.d.ts +3 -3
  30. package/dist/contracts/device-auth.d.ts +21 -15
  31. package/dist/contracts/docs.d.ts +5 -5
  32. package/dist/contracts/domains.d.ts +261 -1330
  33. package/dist/contracts/domains.js +39 -301
  34. package/dist/contracts/email-preferences.d.ts +50 -0
  35. package/dist/contracts/email-preferences.js +44 -0
  36. package/dist/contracts/enums.d.ts +3 -5
  37. package/dist/contracts/enums.js +3 -8
  38. package/dist/contracts/error-code-meta.d.ts +99 -33
  39. package/dist/contracts/error-code-meta.js +36 -9
  40. package/dist/contracts/error-codes.d.ts +1 -1
  41. package/dist/contracts/error-codes.js +28 -11
  42. package/dist/contracts/execution.d.ts +212 -0
  43. package/dist/contracts/execution.js +288 -0
  44. package/dist/contracts/feature-lifecycle.d.ts +498 -2
  45. package/dist/contracts/feature-lifecycle.js +539 -2
  46. package/dist/contracts/features.d.ts +184 -74
  47. package/dist/contracts/features.js +4 -303
  48. package/dist/contracts/functions.d.ts +729 -0
  49. package/dist/contracts/functions.js +485 -0
  50. package/dist/contracts/git.d.ts +1 -1
  51. package/dist/contracts/grants.d.ts +1158 -0
  52. package/dist/contracts/grants.js +696 -0
  53. package/dist/contracts/ids.d.ts +6 -5
  54. package/dist/contracts/ids.js +6 -5
  55. package/dist/contracts/internal.d.ts +3 -3
  56. package/dist/contracts/mcp.d.ts +12 -12
  57. package/dist/contracts/me.js +1 -1
  58. package/dist/contracts/mounts.d.ts +142 -0
  59. package/dist/contracts/mounts.js +56 -0
  60. package/dist/contracts/oauth-resources.d.ts +5 -5
  61. package/dist/contracts/oauth-resources.js +2 -0
  62. package/dist/contracts/operations.d.ts +4 -4
  63. package/dist/contracts/pages.d.ts +6 -14
  64. package/dist/contracts/pages.js +11 -23
  65. package/dist/contracts/plan-policy.d.ts +2 -9
  66. package/dist/contracts/plan-policy.js +1 -4
  67. package/dist/contracts/platform.d.ts +80 -32
  68. package/dist/contracts/platform.js +59 -12
  69. package/dist/contracts/push-new.d.ts +26 -2
  70. package/dist/contracts/push-new.js +8 -0
  71. package/dist/contracts/quotas.d.ts +0 -1
  72. package/dist/contracts/quotas.js +0 -5
  73. package/dist/contracts/repository-connections.d.ts +1 -1
  74. package/dist/contracts/resources.d.ts +21 -85
  75. package/dist/contracts/resources.js +17 -47
  76. package/dist/contracts/runtime-api.d.ts +799 -192
  77. package/dist/contracts/runtime-api.js +231 -100
  78. package/dist/contracts/runtime-app.d.ts +138 -0
  79. package/dist/contracts/runtime-app.js +21 -0
  80. package/dist/contracts/sf-config-v1.d.ts +5 -0
  81. package/dist/contracts/sf-config-v1.js +1 -1
  82. package/dist/contracts/sites.d.ts +17 -12
  83. package/dist/contracts/sites.js +30 -12
  84. package/dist/contracts/space-config.d.ts +46 -117
  85. package/dist/contracts/space-config.js +323 -25
  86. package/dist/contracts/spaces.d.ts +392 -256
  87. package/dist/contracts/spaces.js +163 -83
  88. package/dist/contracts/superadmin-emails.d.ts +27 -3
  89. package/dist/contracts/superadmin-emails.js +10 -15
  90. package/dist/contracts/superadmin-runtime.d.ts +2 -2
  91. package/dist/contracts/superadmin-spaces.d.ts +19 -142
  92. package/dist/contracts/superadmin-spaces.js +7 -0
  93. package/dist/contracts/tags.d.ts +66 -36
  94. package/dist/contracts/tags.js +195 -11
  95. package/dist/contracts/teams.d.ts +9 -9
  96. package/dist/contracts/transfers.d.ts +1 -2
  97. package/dist/contracts/variables.d.ts +4 -4
  98. package/dist/contracts/variables.js +4 -3
  99. package/dist/contracts/webhooks.d.ts +2 -2
  100. package/dist/contracts/zero.d.ts +437 -193
  101. package/dist/contracts/zero.js +149 -158
  102. package/dist/dashboard-paths/index.d.ts +3 -3
  103. package/dist/dashboard-paths/index.js +1 -0
  104. package/dist/docs/agent-prose.js +10 -8
  105. package/dist/docs/agent-setup.d.ts +6 -4
  106. package/dist/docs/agent-setup.js +26 -16
  107. package/dist/docs/agent-solutions.js +1 -1
  108. package/dist/docs/catalog.d.ts +35 -37
  109. package/dist/docs/catalog.js +36 -38
  110. package/dist/docs/error-docs.js +108 -44
  111. package/dist/utils/asset-fingerprint.d.ts +11 -0
  112. package/dist/utils/asset-fingerprint.js +46 -0
  113. package/dist/utils/auth-redirect.d.ts +1 -3
  114. package/dist/utils/auth-redirect.js +1 -21
  115. package/dist/utils/build-settings.d.ts +2 -0
  116. package/dist/utils/build-settings.js +61 -9
  117. package/dist/utils/credential-policy.d.ts +1 -1
  118. package/dist/utils/credential-policy.js +4 -0
  119. package/dist/utils/dns-instructions.d.ts +29 -0
  120. package/dist/utils/dns-instructions.js +78 -0
  121. package/dist/utils/domain-diagnostics.d.ts +29 -0
  122. package/dist/utils/domain-diagnostics.js +63 -0
  123. package/dist/utils/egress-policy.fixtures.json +51 -0
  124. package/dist/utils/grants.d.ts +53 -0
  125. package/dist/utils/grants.js +160 -0
  126. package/dist/utils/id-hints.d.ts +1 -0
  127. package/dist/utils/id-hints.js +55 -13
  128. package/dist/utils/local-space-state.js +1 -0
  129. package/dist/utils/page-colors.d.ts +50 -0
  130. package/dist/utils/page-colors.js +167 -0
  131. package/dist/utils/page-fonts.d.ts +2 -0
  132. package/dist/utils/page-fonts.js +4 -0
  133. package/dist/utils/pages.d.ts +19 -6
  134. package/dist/utils/pages.js +122 -49
  135. package/dist/utils/production-runtime.d.ts +18 -0
  136. package/dist/utils/production-runtime.js +28 -0
  137. package/dist/utils/publish-policy.d.ts +34 -4
  138. package/dist/utils/publish-policy.js +132 -61
  139. package/dist/utils/query-keys.d.ts +7 -13
  140. package/dist/utils/query-keys.js +12 -18
  141. package/dist/utils/source-archive-policy.d.ts +2 -0
  142. package/dist/utils/source-archive-policy.js +2 -0
  143. package/dist/utils/space-config.d.ts +5 -1
  144. package/dist/utils/space-config.js +35 -0
  145. package/dist/utils/static-runtime-policy.d.ts +5 -0
  146. package/dist/utils/static-runtime-policy.fixtures.json +57 -0
  147. package/dist/utils/static-runtime-policy.js +54 -10
  148. package/dist/utils/upload-session.d.ts +3 -0
  149. package/dist/utils/upload-session.js +31 -0
  150. package/package.json +6 -4
  151. package/dist/contracts/annotations.d.ts +0 -327
  152. package/dist/contracts/annotations.js +0 -235
  153. package/dist/contracts/generated-feature-launch-entries.d.ts +0 -2
  154. package/dist/contracts/generated-feature-launch-entries.js +0 -518
  155. package/dist/contracts/publishes.d.ts +0 -135
  156. package/dist/contracts/publishes.js +0 -162
  157. package/dist/utils/access-match.d.ts +0 -39
  158. package/dist/utils/access-match.js +0 -296
  159. package/dist/utils/sf-config-v1.d.ts +0 -2
  160. package/dist/utils/sf-config-v1.js +0 -542
@@ -1,1052 +1,274 @@
1
1
  import { z } from "zod";
2
- // Unified access contracts (internal-docs/access-plan.html the only access
3
- // document).
4
- //
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.
2
+ import { grantConstraintsSchema, grantResourceSetSchema, grantTargetSelectorSchema, } from "./grants.js";
13
3
  // ---------------------------------------------------------------------------
14
- // 1. Grants
4
+ // Canonical sharing model
15
5
  // ---------------------------------------------------------------------------
16
- // A grant is a string in a verified token (or a `requiredGrants` entry on a
17
- // rule). Identity is a string set: the runtime is a verifier and
18
- // set-intersector a visitor passes when `grants ∩ requiredGrants ≠ ∅`.
19
- //
20
- // Grants are globs. One namespace per kind, with `*` wildcards permitted in
21
- // `requiredGrants` so a rule can name a class of identities:
22
- //
23
- // email:alice@acme.com a single verified email
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)
27
- // team:team_123:member top-level team membership at a role
28
- // sub-team:sub_123:member enterprise group membership inside a team
29
- // user:<handle> a specific platform person
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)
38
- //
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.
43
- export const grantSchema = z.string().min(1).max(255);
44
- // Known grant namespace prefixes — final vocabulary (access-plan §1).
45
- export const GRANT_NAMESPACES = [
46
- "email:",
47
- "team:",
48
- "sub-team:",
49
- "user:",
50
- "space:",
51
- "link:",
52
- "invite:",
53
- "svc:",
54
- "sso:",
55
- "ext:",
56
- "pw:",
57
- ];
58
- const GRANT_PATTERN = /^(email|team|sub-team|user|space|link|invite|svc|sso|ext|pw):.+$/;
59
- // Validator for grant globs like "email:*@acme.com", "team:team_123:member",
60
- // "sub-team:sub_123:member", "user:<h>", "svc:…", "ext:{conn}:…". Returns true when the grant string is a
61
- // well-formed `namespace:value`. This is advisory (the runtime intersects
62
- // opaque strings); use it for authoring-surface validation, not enforcement.
63
- export function isWellFormedGrant(grant) {
64
- 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);
65
13
  }
66
- // A `requiredGrants` entry may contain `*` wildcards; a minted token grant must
67
- // be concrete (no `*`). Both share `grantSchema`; this narrows the authoring
68
- // case.
69
- export const requiredGrantSchema = grantSchema.refine(isWellFormedGrant, {
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}:…",
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);
78
- // ---------------------------------------------------------------------------
79
- // 2. Issuer registry
80
- // ---------------------------------------------------------------------------
81
- // A registered signing key. SSO + BYO-issuer keys are registered once at
82
- // team/space level; rules reference grants, and a token's grants are dropped
83
- // unless namespaced to one of `grantNamespaces`. Service tokens are just tokens
84
- // carrying `svc:` grants signed by a platform key.
85
- export const accessKeySchema = z.object({
86
- kid: z.string().min(1).describe("Key id; matches the token header `kid`."),
87
- alg: z.literal("EdDSA"),
88
- publicKey: z.string().min(1).describe("base64url-encoded raw Ed25519 public key."),
89
- // Grants in a token signed by this key are DROPPED unless prefixed by one of
90
- // these. Platform keys list the platform prefixes
91
- // (["team:","sub-team:","user:","email:","space:","link:","svc:","sso:"]);
92
- // external keys list exactly ["ext:{connectionId}:"], bounding a leaked key's
93
- // blast radius to the rules the owner pointed at it.
94
- grantNamespaces: z.array(z.string().min(1)).min(1),
95
- });
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}`;
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;
130
20
  }
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;
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
+ }
143
32
  }
144
- const id = url.slice("connection:".length);
145
- return id.length > 0 && !id.includes("/") && !id.includes(":") ? id : null;
33
+ return false;
146
34
  }
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
- }
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");
165
50
  }
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);
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");
171
62
  }
63
+ segments.pop();
64
+ continue;
172
65
  }
66
+ segments.push(segment);
67
+ }
68
+ if (segments.at(-1) === "index.html") {
69
+ segments.pop();
173
70
  }
174
- return [...ids];
71
+ return segments.length === 0 ? "/" : `/${segments.join("/")}`;
175
72
  }
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"),
73
+ export const accessScopePathSchema = z
74
+ .string()
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
+ }
182
88
  });
183
- const guestEmailDomainsSchema = z
184
- .array(z
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}/`));
95
+ }
96
+ export const authorityReferenceSchema = z
185
97
  .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({
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";
111
+ }
112
+ export const spacePersonSchema = z.object({
193
113
  id: z.string().min(1),
114
+ spaceId: z.string().min(1),
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({
194
126
  teamId: z.string().min(1),
195
- type: accessConnectionTypeSchema,
127
+ teamName: z.string().min(1),
128
+ ruleScopes: z
129
+ .array(accessScopePathSchema)
130
+ .min(1)
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({
146
+ id: z.string().min(1),
147
+ spaceId: z.string().min(1),
148
+ grantId: z.string().min(1),
196
149
  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),
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(),
210
160
  active: z.boolean(),
211
161
  });
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(),
162
+ export const accessShareLinkWithUrlSchema = accessShareLinkSchema.extend({
163
+ url: z.string().url(),
237
164
  });
238
- export const accessConnectionCreateSchema = z.discriminatedUnion("type", [
239
- oidcConnectionCreateSchema,
240
- externalConnectionCreateSchema,
165
+ export const canonicalAccessRequestStatusSchema = z.enum([
166
+ "verification_pending",
167
+ "pending",
168
+ "approved",
169
+ "denied",
170
+ "canceled",
171
+ "expired",
241
172
  ]);
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
- // ---------------------------------------------------------------------------
272
- // 3. The unified runtime Rule (firewall = a rule with no auth)
273
- // ---------------------------------------------------------------------------
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.
293
- ref: z.string().min(1),
294
- transport: z.enum(["basic", "form"]),
295
- username: z.string().min(1).optional(),
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 }))
205
+ .min(1)
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({
242
+ url: z.string().url(),
243
+ expiresAt: z.string().datetime(),
244
+ kind: z.enum(["management", "claim_preview", "person"]),
296
245
  });
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(),
246
+ export const openHandoffCreateSchema = z.object({
247
+ landingPath: accessScopePathSchema.default("/"),
248
+ targetHost: z.string().trim().min(1).max(255).optional(),
306
249
  });
307
- export const acquireSchema = z.discriminatedUnion("type", [
308
- passwordAcquireSchema,
309
- loginAcquireSchema,
310
- ]);
311
- export const ruleAuthSchema = z.object({
312
- // ANY-OF: the visitor's verified grants must intersect this set. Entries may
313
- // be globs (e.g. "email:*@acme.com").
314
- requiredGrants: z.array(grantSchema).min(1),
315
- // Issuer keys whose tokens are accepted for this rule. When omitted, the
316
- // compiler fills in the applicable platform/registered keys. Tokens signed by
317
- // a key contribute only grants within that key's `grantNamespaces`.
318
- issuers: z.array(accessKeySchema).optional(),
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(),
250
+ export const teamSpaceAccessDefaultSchema = z.object({
251
+ newSpaceGrantPreset: spaceAccessPresetSchema,
323
252
  });
324
- // Match conditions AND together (host AND path AND channel AND ipCidrs AND …).
325
- // All fields optional; an empty match matches everything in scope.
326
- export const ruleMatchSchema = z.object({
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(),
332
- // Glob path pattern: "/docs/**" | "/docs/*" | "/x.html".
333
- pathPattern: z.string().min(1).optional(),
334
- // Serving context channel name (e.g. "live", "staging").
335
- channel: z.string().min(1).optional(),
336
- // CIDR v4/v6 list; the request IP must fall in one of these.
337
- ipCidrs: z.array(z.string().min(1)).max(50).optional(),
338
- // User-agent matcher (e.g. block a crawler, gate an agent).
339
- agent: z.string().min(1).optional(),
340
- // ISO 3166-1 alpha-2 country code matcher.
341
- country: z.string().min(2).max(2).optional(),
342
- // Single request-header matcher: name + expected value.
343
- header: z.object({ name: z.string().min(1), value: z.string() }).optional(),
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);
358
- // The one Rule. `match` (AND) selects requests; `effect` is what happens on
359
- // match; `auth` (absent = anonymous firewall) gates `challenge`/`allow`.
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(),
364
- match: ruleMatchSchema.default({}),
365
- effect: z.enum(["allow", "deny", "challenge"]),
366
- // Absent = anonymous firewall rule (no credential required). Present = pass
367
- // when token grants ∩ requiredGrants ≠ ∅ (the one satisfaction test).
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(),
376
- // Surfaced on a `deny` (X-Spacefast-Reason header) and the rendered platform
377
- // page so an operator/visitor can tell which rule fired and why. Diagnostic
378
- // only — never load-bearing for ownership or semantics.
379
- reasonCode: z.string().min(1).optional(),
380
- message: z.string().min(1).optional(),
253
+ export const teamSpaceAccessDefaultWriteSchema = z.object({
254
+ newSpaceGrantPreset: spaceAccessPresetSchema,
381
255
  });
382
256
  // ---------------------------------------------------------------------------
383
- // 3b. Visitor token (the cloud "mint" half)
257
+ // Unified visitor and collaboration tokens
384
258
  // ---------------------------------------------------------------------------
385
- // The header `kid`/`alg` the platform signs visitor tokens with. These match
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
388
- // NOT introduce a second key system — these constants name the existing one.
389
259
  export const ACCESS_TOKEN_KID = "spacefast-runtime-v1";
390
260
  export const ACCESS_TOKEN_ALG = "EdDSA";
391
- // The published JWKS path (served under /.well-known/ by the control plane and
392
- // fetched by the runtime + Cast).
393
261
  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";
400
- // The claim set carried by a minted visitor token. `grants` are concrete
401
- // (no `*`), `email:`/`user:`/`team:<teamId>:<role>`/`sub-team:<subTeamId>:<role>` namespaced so they survive
402
- // the platform issuer's `grantNamespaces` filter at the runtime. `aud`, when
403
- // present, lowercases-binds the token to a single serving host.
404
262
  export const accessTokenPayloadSchema = z.object({
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).
407
- sub: z.string().min(1),
408
- // Concrete (non-glob) grants. The runtime intersects these with a rule's
409
- // `requiredGrants`; the visitor passes when the intersection is non-empty.
410
- grants: z.array(grantSchema).default([]),
411
- // Unix seconds. Mirrors the runtime's exp check (300s leeway).
263
+ sub: authorityReferenceSchema,
264
+ authorities: z.array(authorityReferenceSchema).min(1).max(16),
412
265
  exp: z.number().int().nonnegative(),
413
- nbf: z.number().int().nonnegative().optional(),
414
- iat: z.number().int().nonnegative().optional(),
415
- iss: z.string().min(1).optional(),
416
- // Optional host binding (lowercased) — when set the token is only accepted on
417
- // that serving host.
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(),
428
- });
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.
433
- export const authorizeRequestSchema = z.object({
434
- aud: z.string().min(1).optional(),
435
- });
436
- // Authorize response: the signed compact JWT plus its decoded claims (so a
437
- // caller can show/inspect the grants without re-parsing).
438
- export const authorizeResponseSchema = z.object({
439
- // The signed compact EdDSA JWT (`header.claims.signature`, base64url).
440
- token: z.string().min(1),
441
- // Decoded claims of `token` (echo of what was signed).
442
- payload: accessTokenPayloadSchema,
443
- // Unix seconds the token expires at (convenience mirror of `payload.exp`).
444
- expiresAt: z.number().int().nonnegative(),
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(),
463
- });
464
- // ---------------------------------------------------------------------------
465
- // 3c. Mint-time grant builders (the cloud "resolve identity → grants" half)
466
- // ---------------------------------------------------------------------------
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.
471
- export function spaceLevelGrant(spaceId, level) {
472
- return `space:${spaceId}:${level}`;
473
- }
474
- // The concrete grant a share-link holder carries: a token with `link:{linkId}`
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
477
- // and the token goes inert. The platform issuer lists the `link:` namespace.
478
- export function shareLinkGrant(linkId) {
479
- return `link:${linkId}`;
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
- }
491
- // ---------------------------------------------------------------------------
492
- // 3d. Share links + the sharing projection (access-plan §5.1/§5.2, X-33)
493
- // ---------------------------------------------------------------------------
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).
625
- export const spaceInviteSchema = z.object({
626
- id: z.string().min(1),
627
- spaceId: z.string().min(1),
628
- email: z.string().min(1).nullable().default(null),
629
- invitedBy: 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(),
634
- acceptedAt: z.string().min(1).nullable().default(null),
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),
638
- });
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({
648
- id: z.string().min(1),
266
+ nbf: z.number().int().nonnegative(),
267
+ iat: z.number().int().nonnegative(),
268
+ iss: z.string().min(1),
269
+ aud: z.string().min(1),
649
270
  spaceId: z.string().min(1),
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),
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),
897
- });
898
- // ---------------------------------------------------------------------------
899
- // 4. Lanes / nouns (additive types only — not wired)
900
- // ---------------------------------------------------------------------------
901
- // A channel is a named serving lane (e.g. "live", "staging") pointing at a
902
- // version, with promote/rollback history.
903
- export const channelSchema = z.object({
904
- name: z.string().min(1),
905
- versionId: z.string().min(1),
906
- // Newest-first promote/rollback log: which version this channel pointed at.
907
- history: z
908
- .array(z.object({
909
- versionId: z.string().min(1),
910
- at: z.string().min(1).describe("ISO 8601 timestamp."),
911
- }))
912
- .default([]),
913
- });
914
- // Content-lane serving config (scalar, field-ownership / SSA semantics). No
915
- // `spa` toggle — that mode is gone; a SPA is expressed as a `fallback`.
916
- export const servingConfigSchema = z.object({
917
- // Directory index document (e.g. "index.html").
918
- index: z.string().min(1).optional(),
919
- // Single fallback document served when no file/route matches (replaces the
920
- // old `spa` boolean).
921
- fallback: z.string().min(1).optional(),
922
- // Whether directory listings are rendered when there is no index.
923
- listing: z.boolean().optional(),
924
- });
925
- // The policy-lane document: an ordered, additive list of unified Rules. This is
926
- // the firewall+access lane (own version + rollback, independent of content).
927
- export const policyDocSchema = z.object({
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(),
935
- });
936
- // ---------------------------------------------------------------------------
937
- // 5. Storage + resolution shapes (additive)
938
- // ---------------------------------------------------------------------------
939
- // The CLOUD-stored policy for one space. This is the persisted, versioned form
940
- // of the policy lane: an ordered list of unified Rules plus a monotonic
941
- // `version` so the lane can promote/rollback independently of content (mirrors
942
- // `channelSchema.history`). `version` is bumped on every write; a write that
943
- // supplies a stale `version` is a lost-update and is rejected by the resolver.
944
- //
945
- // Provenance: these are the "cloud" rules. They are merged with any "file"
946
- // rules compiled from the space's `sf.jsonc` (which travel + roll back
947
- // WITH a content version) to produce the effective policy — see
948
- // `effectiveRuleSchema` / `effectivePolicySchema`.
949
- export const spacePolicySchema = z.object({
950
- // Ordered, additive Rules (first-match-wins at serving compile time). Same
951
- // unified Rule the runtime verifies; an absent `auth` is an anonymous
952
- // firewall rule (firewall ⊂ access).
953
- rules: z.array(runtimeRuleSchema).default([]),
954
- // Monotonic lane version. Starts at 1 on first write; every accepted write
955
- // increments it. Used for optimistic concurrency (lost-update rejection) and
956
- // for rollback to a prior lane revision.
957
- version: z.number().int().nonnegative().default(0),
958
- });
959
- // GET/PUT /v1/spaces/:id/access-rules wire schemas — the ordered, always-latest
960
- // access policy pair. Shared by the still-mounted /access route family and the
961
- // detached /access-rules core adapter so activation has exactly one schema to
962
- // retire, not two to reconcile.
963
- export const spaceAccessRulesWriteSchema = z.object({
964
- rules: z.array(runtimeRuleSchema).default([]),
965
- expectedVersion: z.number().int().nonnegative().optional(),
966
- });
967
- // The lane read plus the plan-downgrade marker (access-plan X-23): plan-gated
968
- // rules keep enforcing after a downgrade; this flag is the notice surface.
969
- export const spaceAccessRulesReadSchema = spacePolicySchema.extend({
970
- accessExceedsPlan: z.boolean().default(false),
971
- });
972
- // PUT/DELETE /v1/spaces/:id/quick-share wire schemas — the one active
973
- // whole-space guest link (§5.2).
974
- export const quickShareSchema = z.object({
975
- kind: z.literal("quick_share"),
976
- url: z.string().url(),
977
- expiresAt: z.string().min(1),
978
- outcome: z.enum(["created", "reused"]),
979
- });
980
- export const quickShareDeleteSchema = z.object({
981
- outcome: z.enum(["deleted", "unchanged"]),
982
- });
983
- // GET/PUT /v1/spaces/:id/enhancements wire schemas — whole-space page
984
- // enhancements (analytics, tag manager, custom scripts) on/off. `active` is
985
- // reserved and currently always empty (see the composition delta on
986
- // spaces/enhancements-service.ts).
987
- export const enhancementsSchema = z.object({
988
- enabled: z.boolean(),
989
- active: z.array(z.string()),
990
- });
991
- export const enhancementsWriteSchema = z.object({ enabled: z.boolean() });
992
- // Where an effective rule came from. `platform` = composed by the cloud from
993
- // platform state (plan window, abuse/ban/billing) and PREPENDED above every
994
- // user-authored rule so a platform decision always wins first-match-wins; the
995
- // user can NEVER author a `platform` rule. `file` = compiled from the space's
996
- // `sf.jsonc` (timeless, rolls back with the content version). `cloud` =
997
- // the stored `spacePolicySchema` lane (incident interventions, password walls
998
- // set via the API/CLI, etc.). Provenance is surfaced so an operator reading the
999
- // effective policy can tell which surface to edit to change a given rule.
1000
- export const ruleSourceSchema = z.enum(["platform", "file", "cloud"]);
1001
- // A provenance-annotated effective Rule: the unified `runtimeRuleSchema` plus
1002
- // the `source` it was contributed by and the `order` it occupies in the
1003
- // resolved, first-match-wins list. This is the READ shape returned by the
1004
- // effective-policy resolver — never stored. `order` is the 0-based index in the
1005
- // flattened effective list (file rules then cloud rules, or whatever order the
1006
- // resolver composes), letting a caller reconstruct match precedence.
1007
- export const effectiveRuleSchema = runtimeRuleSchema.extend({
1008
- source: ruleSourceSchema,
1009
- order: z.number().int().nonnegative(),
1010
- });
1011
- // The resolved effective policy for a space: the flattened, provenance-tagged
1012
- // rule list plus the `cloudVersion` (the `spacePolicySchema.version` the cloud
1013
- // rules were resolved from) so a reader can detect drift / pin a write against
1014
- // the revision it observed. `fileVersion` is the content version id the `file`
1015
- // rules were compiled from (null when the space has no compiled file rules).
1016
- export const effectivePolicySchema = z.object({
1017
- rules: z.array(effectiveRuleSchema).default([]),
1018
- // Space-wide verifier issuers. The control plane always emits the platform
1019
- // issuer here so identity-only public-space tokens verify without a rule.
1020
- issuers: z.array(accessKeySchema).default([]),
1021
- cloudVersion: z.number().int().nonnegative().default(0),
1022
- fileVersion: z.string().min(1).nullable().default(null),
1023
- // Serving-secret map (name -> value) the resolved rules reference by
1024
- // password-acquire `ref` = "secret:<name>". Carries the space password's
1025
- // bcrypt verifier hash, keyed so the runtime resolves it from
1026
- // `serving['secrets']` and `password_verify`s against it. Never contains
1027
- // plaintext the visitor types — only stored verifier hashes / shared secrets.
1028
- secrets: z.record(z.string().min(1), z.string().min(1)).default({}),
1029
- // Space session version (access-plan §3.1/§4.4): visitor tokens must carry a
1030
- // matching `sv` claim. Bumped by logout-all; also an input to the space-local
1031
- // `pw:` key derivation, so a bump invalidates every wall pass with zero new
1032
- // state.
1033
- sessionVersion: z.number().int().nonnegative().default(0),
1034
- });
1035
- // ---------------------------------------------------------------------------
1036
- // 6. Simulate (access-plan §5.5, X-13)
1037
- // ---------------------------------------------------------------------------
1038
- // GET /v1/spaces/:id/access/simulate?host&path[&email&ip&country] → the rule
1039
- // that would fire and why. Runs on the parity-pinned TS matcher, so simulate
1040
- // correctness is a corollary of the corpus.
1041
- export const accessSimulateResultSchema = z.object({
1042
- verdict: z.enum(["allow", "deny", "challenge"]),
1043
- // The first effective rule (with lane/source + order) whose match applies
1044
- // and whose effect resolves for the simulated identity; null = no rule
1045
- // gates the request (public).
1046
- matchedRule: effectiveRuleSchema.nullable().default(null),
1047
- // The matched rule's requiredGrants — which grants would satisfy it.
1048
- why: z.array(grantSchema).default([]),
1049
- // Human audience summary derived from requiredGrants ("Team members",
1050
- // "Anyone with an active share link", …).
1051
- whoWouldPass: z.array(z.string()).default([]),
271
+ generation: z.number().int().nonnegative(),
272
+ emailVerified: z.boolean().default(false),
273
+ jti: z.string().min(1),
1052
274
  });