@antasphere/chassis-contract 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/LICENSE +82 -0
  2. package/dist/define.d.ts +177 -0
  3. package/dist/define.d.ts.map +1 -0
  4. package/dist/define.js +35 -0
  5. package/dist/define.js.map +1 -0
  6. package/dist/entitlements.d.ts +486 -0
  7. package/dist/entitlements.d.ts.map +1 -0
  8. package/dist/entitlements.js +344 -0
  9. package/dist/entitlements.js.map +1 -0
  10. package/dist/identity.d.ts +56 -0
  11. package/dist/identity.d.ts.map +1 -0
  12. package/dist/identity.js +2 -0
  13. package/dist/identity.js.map +1 -0
  14. package/dist/index.d.ts +23 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +29 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/routes/index.d.ts +5403 -0
  19. package/dist/routes/index.d.ts.map +1 -0
  20. package/dist/routes/index.js +940 -0
  21. package/dist/routes/index.js.map +1 -0
  22. package/dist/schemas/api-keys.d.ts +54 -0
  23. package/dist/schemas/api-keys.d.ts.map +1 -0
  24. package/dist/schemas/api-keys.js +48 -0
  25. package/dist/schemas/api-keys.js.map +1 -0
  26. package/dist/schemas/audit.d.ts +74 -0
  27. package/dist/schemas/audit.d.ts.map +1 -0
  28. package/dist/schemas/audit.js +65 -0
  29. package/dist/schemas/audit.js.map +1 -0
  30. package/dist/schemas/break-glass.d.ts +42 -0
  31. package/dist/schemas/break-glass.d.ts.map +1 -0
  32. package/dist/schemas/break-glass.js +38 -0
  33. package/dist/schemas/break-glass.js.map +1 -0
  34. package/dist/schemas/cli-auth.d.ts +59 -0
  35. package/dist/schemas/cli-auth.d.ts.map +1 -0
  36. package/dist/schemas/cli-auth.js +68 -0
  37. package/dist/schemas/cli-auth.js.map +1 -0
  38. package/dist/schemas/common.d.ts +96 -0
  39. package/dist/schemas/common.d.ts.map +1 -0
  40. package/dist/schemas/common.js +205 -0
  41. package/dist/schemas/common.js.map +1 -0
  42. package/dist/schemas/demo-passes.d.ts +90 -0
  43. package/dist/schemas/demo-passes.d.ts.map +1 -0
  44. package/dist/schemas/demo-passes.js +60 -0
  45. package/dist/schemas/demo-passes.js.map +1 -0
  46. package/dist/schemas/files.d.ts +39 -0
  47. package/dist/schemas/files.d.ts.map +1 -0
  48. package/dist/schemas/files.js +26 -0
  49. package/dist/schemas/files.js.map +1 -0
  50. package/dist/schemas/instance.d.ts +58 -0
  51. package/dist/schemas/instance.d.ts.map +1 -0
  52. package/dist/schemas/instance.js +70 -0
  53. package/dist/schemas/instance.js.map +1 -0
  54. package/dist/schemas/invitations.d.ts +100 -0
  55. package/dist/schemas/invitations.d.ts.map +1 -0
  56. package/dist/schemas/invitations.js +55 -0
  57. package/dist/schemas/invitations.js.map +1 -0
  58. package/dist/schemas/me.d.ts +81 -0
  59. package/dist/schemas/me.d.ts.map +1 -0
  60. package/dist/schemas/me.js +178 -0
  61. package/dist/schemas/me.js.map +1 -0
  62. package/dist/schemas/members.d.ts +60 -0
  63. package/dist/schemas/members.d.ts.map +1 -0
  64. package/dist/schemas/members.js +43 -0
  65. package/dist/schemas/members.js.map +1 -0
  66. package/dist/schemas/projects.d.ts +232 -0
  67. package/dist/schemas/projects.d.ts.map +1 -0
  68. package/dist/schemas/projects.js +140 -0
  69. package/dist/schemas/projects.js.map +1 -0
  70. package/dist/schemas/scope.d.ts +14 -0
  71. package/dist/schemas/scope.d.ts.map +1 -0
  72. package/dist/schemas/scope.js +5 -0
  73. package/dist/schemas/scope.js.map +1 -0
  74. package/dist/schemas/setup.d.ts +28 -0
  75. package/dist/schemas/setup.d.ts.map +1 -0
  76. package/dist/schemas/setup.js +28 -0
  77. package/dist/schemas/setup.js.map +1 -0
  78. package/dist/schemas/sso-connect.d.ts +30 -0
  79. package/dist/schemas/sso-connect.d.ts.map +1 -0
  80. package/dist/schemas/sso-connect.js +38 -0
  81. package/dist/schemas/sso-connect.js.map +1 -0
  82. package/dist/schemas/sso-logout.d.ts +17 -0
  83. package/dist/schemas/sso-logout.d.ts.map +1 -0
  84. package/dist/schemas/sso-logout.js +16 -0
  85. package/dist/schemas/sso-logout.js.map +1 -0
  86. package/dist/schemas/teams.d.ts +100 -0
  87. package/dist/schemas/teams.d.ts.map +1 -0
  88. package/dist/schemas/teams.js +84 -0
  89. package/dist/schemas/teams.js.map +1 -0
  90. package/dist/schemas/workspaces.d.ts +99 -0
  91. package/dist/schemas/workspaces.d.ts.map +1 -0
  92. package/dist/schemas/workspaces.js +96 -0
  93. package/dist/schemas/workspaces.js.map +1 -0
  94. package/dist/seams.d.ts +185 -0
  95. package/dist/seams.d.ts.map +1 -0
  96. package/dist/seams.js +37 -0
  97. package/dist/seams.js.map +1 -0
  98. package/dist/wire.d.ts +101 -0
  99. package/dist/wire.d.ts.map +1 -0
  100. package/dist/wire.js +161 -0
  101. package/dist/wire.js.map +1 -0
  102. package/package.json +41 -3
  103. package/src/define.ts +64 -0
  104. package/src/entitlements.ts +527 -0
  105. package/src/identity.ts +49 -0
  106. package/src/index.ts +38 -0
  107. package/src/routes/index.ts +1146 -0
  108. package/src/schemas/api-keys.ts +53 -0
  109. package/src/schemas/audit.ts +74 -0
  110. package/src/schemas/break-glass.ts +46 -0
  111. package/src/schemas/cli-auth.ts +78 -0
  112. package/src/schemas/common.ts +206 -0
  113. package/src/schemas/demo-passes.ts +73 -0
  114. package/src/schemas/files.ts +30 -0
  115. package/src/schemas/instance.ts +71 -0
  116. package/src/schemas/invitations.ts +66 -0
  117. package/src/schemas/me.ts +184 -0
  118. package/src/schemas/members.ts +53 -0
  119. package/src/schemas/projects.ts +174 -0
  120. package/src/schemas/scope.ts +18 -0
  121. package/src/schemas/setup.ts +31 -0
  122. package/src/schemas/sso-connect.ts +40 -0
  123. package/src/schemas/sso-logout.ts +17 -0
  124. package/src/schemas/teams.ts +101 -0
  125. package/src/schemas/workspaces.ts +109 -0
  126. package/src/seams.ts +202 -0
  127. package/src/wire.ts +246 -0
  128. package/test/define.test.ts +59 -0
  129. package/test/hub-wire.test.ts +114 -0
  130. package/README.md +0 -3
@@ -0,0 +1,53 @@
1
+ import { z } from 'zod';
2
+ import { plainText } from './common.js';
3
+ import type { ScopeSchema } from './scope.js';
4
+
5
+ /** The API-key schemas carry the tool's scope vocabulary: built per tool (../define.ts). */
6
+ export function defineApiKeySchemas<TScope extends string>(scopeSchema: ScopeSchema<TScope>) {
7
+ const apiKeySchema = z.object({
8
+ id: z.string(),
9
+ name: z.string(),
10
+ keyId: z.string(),
11
+ scopes: z.array(scopeSchema),
12
+ /**
13
+ * The key's optional workspace PIN (user-scoped credential model). null =
14
+ * user-scoped: the key acts as its creator and the target workspace is a
15
+ * per-request parameter (X-Workspace-Id / the user's default). A value =
16
+ * the key reaches ONLY that workspace, forever.
17
+ */
18
+ workspaceId: z.string().nullable(),
19
+ createdBy: z.string(),
20
+ createdAt: z.string(),
21
+ lastUsedAt: z.string().nullable(),
22
+ revokedAt: z.string().nullable(),
23
+ expiresAt: z.string().nullable()
24
+ });
25
+
26
+ const apiKeysListSchema = z.object({
27
+ apiKeys: z.array(apiKeySchema),
28
+ nextCursor: z.string().nullable()
29
+ });
30
+
31
+ const apiKeyCreateSchema = z.object({
32
+ name: plainText(1, 120),
33
+ scopes: z.array(scopeSchema).min(1),
34
+ /** TTL at mint; the server computes the absolute expiry. Omit = never expires. */
35
+ expiresInDays: z.number().int().min(1).max(3650).optional(),
36
+ /**
37
+ * Optional workspace PIN (least privilege): the key then reaches ONLY this
38
+ * workspace — an ACTIVE membership of it is required at mint. Omit for a
39
+ * user-scoped key (the default): it acts as you in whichever of your
40
+ * workspaces each request names.
41
+ */
42
+ workspaceId: z.uuid().optional()
43
+ });
44
+
45
+ /** The secret appears exactly once, in this response. */
46
+ const apiKeyCreatedSchema = z.object({
47
+ apiKey: apiKeySchema,
48
+ /** Full key `<prefix>_<keyid>_<secret>` — shown once, never retrievable. */
49
+ key: z.string()
50
+ });
51
+
52
+ return { apiKeySchema, apiKeysListSchema, apiKeyCreateSchema, apiKeyCreatedSchema };
53
+ }
@@ -0,0 +1,74 @@
1
+ import { z } from 'zod';
2
+ import { cursorPageQuerySchema, noControlChars } from './common.js';
3
+
4
+ export const auditEntrySchema = z.object({
5
+ id: z.number(),
6
+ actorUserId: z.string().nullable(),
7
+ actorEmail: z.string().nullable(),
8
+ actorVia: z.enum(['session', 'api_key', 'oauth', 'system']),
9
+ apiKeyId: z.string().nullable(),
10
+ action: z.string(),
11
+ resourceType: z.string(),
12
+ resourceId: z.string().nullable(),
13
+ requestId: z.string().nullable(),
14
+ ip: z.string().nullable(),
15
+ metadata: z.unknown().nullable(),
16
+ createdAt: z.string()
17
+ });
18
+ export type AuditEntry = z.infer<typeof auditEntrySchema>;
19
+
20
+ export const auditViaSchema = z.enum(['session', 'api_key', 'oauth', 'system']);
21
+ export type AuditVia = z.infer<typeof auditViaSchema>;
22
+
23
+ /** The actor filter's reserved word: rows nobody signed (actor_user_id IS NULL). */
24
+ export const AUDIT_ACTOR_SYSTEM = 'system';
25
+
26
+ /**
27
+ * A comma-separated list of actions or families: an item ending in `.`
28
+ * matches the family (`apikey.` is every API key action), any
29
+ * other item matches exactly. The fallback action of an unlabelled route is
30
+ * `<method> <path>`, so a space and a slash are legitimate characters.
31
+ */
32
+ const AUDIT_ACTION_LIST_RE = /^[a-z0-9_.\-/ ]+(,[a-z0-9_.\-/ ]+)*$/;
33
+ const AUDIT_VIA_LIST_RE = /^(session|api_key|oauth|system)(,(session|api_key|oauth|system))*$/;
34
+
35
+ /**
36
+ * The audit list's filters, every one optional and combined with AND. Each
37
+ * free-text one is bounded and control-char free: every value lands in a
38
+ * WHERE comparison, and a NUL there is a driver error (22021) that would
39
+ * surface as a 500 for a plain client mistake.
40
+ */
41
+ export const auditListQuerySchema = cursorPageQuerySchema.extend({
42
+ /** Free text, matched case-insensitively against the actor's email and the action. */
43
+ q: noControlChars(z.string().max(120)).optional(),
44
+ /** Actions or families, comma-separated (`member.,apikey.create`); at most 20. */
45
+ action: z
46
+ .string()
47
+ .max(600)
48
+ .regex(AUDIT_ACTION_LIST_RE)
49
+ .refine((v) => v.split(',').length <= 20, { error: 'at most 20 actions' })
50
+ .optional(),
51
+ /** How the actor authenticated, comma-separated (`session,api_key`). */
52
+ actorVia: z.string().max(40).regex(AUDIT_VIA_LIST_RE).optional(),
53
+ /** One user id (a member of the workspace), or `system` for rows nobody signed. */
54
+ actor: noControlChars(z.string().min(1).max(64)).optional(),
55
+ resourceType: noControlChars(z.string().min(1).max(64)).optional(),
56
+ resourceId: noControlChars(z.string().min(1).max(200)).optional(),
57
+ /** Entries created at or after this instant (ISO 8601). */
58
+ from: z.iso.datetime().optional(),
59
+ /** Entries created at or before this instant (ISO 8601). */
60
+ to: z.iso.datetime().optional()
61
+ });
62
+ export type AuditListQuery = z.infer<typeof auditListQuerySchema>;
63
+
64
+ export const auditListSchema = z.object({
65
+ entries: z.array(auditEntrySchema),
66
+ nextCursor: z.string().nullable(),
67
+ /**
68
+ * How many entries match the filters in all: counted on the first page
69
+ * only (no cursor), null on the pages after it. A client keeps the first
70
+ * page's figure while it loads more.
71
+ */
72
+ total: z.number().int().nullable()
73
+ });
74
+ export type AuditListResponse = z.infer<typeof auditListSchema>;
@@ -0,0 +1,46 @@
1
+ import { z } from 'zod';
2
+ import { workspaceRoleSchema } from './common.js';
3
+
4
+ /**
5
+ * Break-glass superadmin recovery (ADR 010). Session-only endpoints gated by
6
+ * the SUPERADMIN_EMAILS env allowlist — deliberately unlisted in the machine
7
+ * scope allowlist, so API keys and OAuth tokens 403 fail-closed.
8
+ */
9
+
10
+ /**
11
+ * Optional target user (omitted = the calling superadmin) and target
12
+ * workspace. `workspaceId` may be omitted ONLY while the instance runs a
13
+ * single workspace (every pre-ADR-012 deployment); a multi-workspace
14
+ * instance answers 400 workspace_required — recovery must name its target
15
+ * explicitly, never guess one.
16
+ */
17
+ export const breakGlassClaimOwnershipRequestSchema = z.object({
18
+ userId: z.string().min(1).optional(),
19
+ workspaceId: z.uuid().optional()
20
+ });
21
+ export type BreakGlassClaimOwnershipRequest = z.infer<typeof breakGlassClaimOwnershipRequestSchema>;
22
+
23
+ export const breakGlassClaimOwnershipSchema = z.object({
24
+ memberId: z.string(),
25
+ userId: z.string(),
26
+ email: z.string(),
27
+ /** The workspace the ownership was claimed in. */
28
+ workspaceId: z.string(),
29
+ role: workspaceRoleSchema,
30
+ isActive: z.boolean(),
31
+ /** true when the membership was created; false when an existing row was promoted/reactivated. */
32
+ created: z.boolean()
33
+ });
34
+ export type BreakGlassClaimOwnership = z.infer<typeof breakGlassClaimOwnershipSchema>;
35
+
36
+ export const breakGlassResetTwoFactorRequestSchema = z.object({
37
+ userId: z.string().min(1)
38
+ });
39
+ export type BreakGlassResetTwoFactorRequest = z.infer<typeof breakGlassResetTwoFactorRequestSchema>;
40
+
41
+ export const breakGlassResetTwoFactorSchema = z.object({
42
+ userId: z.string(),
43
+ /** true when a factor actually existed (row or enabled flag) before the reset. */
44
+ hadTwoFactor: z.boolean()
45
+ });
46
+ export type BreakGlassResetTwoFactor = z.infer<typeof breakGlassResetTwoFactorSchema>;
@@ -0,0 +1,78 @@
1
+ import { z } from 'zod';
2
+ import { plainText } from './common.js';
3
+
4
+ /**
5
+ * Browserless CLI/agent sign-in: email OTP → API key. Two PUBLIC (pre-auth)
6
+ * endpoints, mounted like /setup outside the principal machinery:
7
+ *
8
+ * POST /cli/auth/request — send a 6-digit sign-in code to the email
9
+ * POST /cli/auth/complete — verify the code, mint an API key
10
+ *
11
+ * Sign-up stays CLOSED: the flow rides the emailOTP plugin with
12
+ * `disableSignUp: true`, so a code only ever signs in an EXISTING account
13
+ * (created via setup, a workspace invitation, or a collaborator claim).
14
+ * `request` answers a generic success for unknown emails (no enumeration);
15
+ * `complete` for an unknown email fails exactly like a wrong code. On the
16
+ * CLOUD edition both refuse (403 cli_otp_disabled) — hub-only login (D1).
17
+ *
18
+ * DELETE /cli/auth/key is the logout counterpart: it revokes exactly the
19
+ * PRESENTING API key (self-revocation — possession is the authority to kill
20
+ * itself), machine-allowed under the tool's write scope in the scope allowlist.
21
+ */
22
+
23
+ export const cliAuthRequestSchema = z.object({
24
+ email: z.email()
25
+ });
26
+ export type CliAuthRequest = z.infer<typeof cliAuthRequestSchema>;
27
+
28
+ /** Always `sent: true` on 2xx — deliberately silent about account existence. */
29
+ export const cliAuthRequestedSchema = z.object({
30
+ sent: z.literal(true)
31
+ });
32
+ export type CliAuthRequested = z.infer<typeof cliAuthRequestedSchema>;
33
+
34
+ export const cliAuthCompleteSchema = z.object({
35
+ email: z.email(),
36
+ otp: z.string().min(4).max(16),
37
+ /** Display name for the minted key (defaults to a CLI-login label). */
38
+ keyName: plainText(1, 120).optional(),
39
+ /** TTL at mint; the server computes the absolute expiry. Omit = never expires. */
40
+ expiresInDays: z.number().int().min(1).max(3650).optional(),
41
+ /**
42
+ * Optional workspace PIN (least privilege) — the account must hold an
43
+ * ACTIVE membership of it. Omit for a user-scoped key (the default): it
44
+ * acts as you in whichever of your workspaces each request names
45
+ * (X-Workspace-Id / your default membership).
46
+ */
47
+ workspaceId: z.uuid().optional()
48
+ });
49
+ export type CliAuthComplete = z.infer<typeof cliAuthCompleteSchema>;
50
+
51
+ /** DELETE /cli/auth/key — the presenting key was revoked (CLI logout). */
52
+ export const cliAuthRevokedSchema = z.object({
53
+ revoked: z.literal(true),
54
+ id: z.string()
55
+ });
56
+ export type CliAuthRevoked = z.infer<typeof cliAuthRevokedSchema>;
57
+
58
+ /**
59
+ * The one CLI-auth schema that embeds the API key (and with it the tool's
60
+ * scope vocabulary): built per tool (../define.ts).
61
+ */
62
+ export function defineCliAuthSchemas<TApiKey extends z.ZodType>(apiKeySchema: TApiKey) {
63
+ /** The full key appears exactly once, in this response (API-key semantics). */
64
+ const cliAuthCompletedSchema = z.object({
65
+ /** Full key `<prefix>_<keyid>_<secret>` — shown once, never retrievable. */
66
+ key: z.string(),
67
+ apiKey: apiKeySchema,
68
+ user: z.object({
69
+ id: z.string(),
70
+ email: z.string(),
71
+ name: z.string()
72
+ }),
73
+ /** The workspace the key is PINNED to; null = user-scoped (unpinned). */
74
+ workspaceId: z.string().nullable()
75
+ });
76
+
77
+ return { cliAuthCompletedSchema };
78
+ }
@@ -0,0 +1,206 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * API conventions (fixed): plain resource JSON on success — no
5
+ * `{ success, data }` envelope; errors are `{ error: { code, message,
6
+ * details? } }`; cursor pagination.
7
+ */
8
+ export const apiErrorSchema = z.object({
9
+ error: z.object({
10
+ code: z.string(),
11
+ message: z.string(),
12
+ details: z.unknown().optional()
13
+ })
14
+ });
15
+ export type ApiError = z.infer<typeof apiErrorSchema>;
16
+
17
+ export const workspaceRoleSchema = z.enum(['owner', 'admin', 'member']);
18
+ export type WorkspaceRole = z.infer<typeof workspaceRoleSchema>;
19
+
20
+ export const cursorPageQuerySchema = z.object({
21
+ cursor: z.string().optional(),
22
+ limit: z.coerce.number().int().min(1).max(100).default(50)
23
+ });
24
+ /**
25
+ * Free text a client may send that ends up in a Postgres `text`/`varchar`
26
+ * column, a filename, or a log line.
27
+ *
28
+ * Postgres cannot store a NUL byte in a text value: it raises SQLSTATE 22021
29
+ * from deep inside the driver, which surfaces as an unhandled 500 whose
30
+ * error-level log line prints the failing statement AND its bound parameters.
31
+ * An anonymous client can drive that on any free-text sink (`/setup`, the
32
+ * invitation accept, the CLI key mint). NUL is never meaningful in a name, a
33
+ * label, or a title, so the contract refuses it and the caller gets the
34
+ * ordinary 400 validation_error instead.
35
+ *
36
+ * The other C0 controls are rejected with it: they are invisible in every UI,
37
+ * they are the raw material of log-injection and terminal-escape tricks, and
38
+ * no legitimate name contains one. Newlines and tabs are ALLOWED — multi-line
39
+ * free text is a real thing; a sink that must stay single-line adds its own
40
+ * `.regex()`.
41
+ */
42
+ export function hasControlChars(value: string): boolean {
43
+ for (let i = 0; i < value.length; i++) {
44
+ const code = value.charCodeAt(i);
45
+ // Tab / LF / CR are legitimate in multi-line free text.
46
+ if (code === 0x09 || code === 0x0a || code === 0x0d) continue;
47
+ if (code < 0x20 || code === 0x7f) return true;
48
+ }
49
+ return false;
50
+ }
51
+
52
+ export function noControlChars<T extends z.ZodType<string>>(schema: T): T {
53
+ return schema.refine((v) => !hasControlChars(v), {
54
+ error: 'must not contain NUL or other control characters'
55
+ }) as unknown as T;
56
+ }
57
+
58
+ /**
59
+ * Postgres int4 ceiling. Every integer the API accepts that lands in an
60
+ * `integer` column (version numbers, cursors) must be bounded by it at the
61
+ * contract: `Number.isInteger(1e15)` is true, so a bare `.int()` lets a value
62
+ * past bigint-free column range through to Postgres, which refuses it with
63
+ * a numeric-overflow error that would surface as a 500.
64
+ */
65
+ export const INT4_MAX = 2_147_483_647;
66
+
67
+ /**
68
+ * True when the value graph carries a NUL character in any string — object
69
+ * keys included. Postgres cannot store NUL in `text` (SQLSTATE 22021) or in
70
+ * `jsonb` (`\u0000` raises 22P05), so any client-supplied structure bound for
71
+ * a jsonb column (annotation selections, form payloads, a resource's metadata) is
72
+ * refused at the contract instead of surfacing a driver error. Iterative walk
73
+ * — never recursion — so a deeply nested value cannot blow the stack in the
74
+ * very guard meant to protect against hostile shapes.
75
+ */
76
+ export function hasNulDeep(value: unknown): boolean {
77
+ const stack: unknown[] = [value];
78
+ // A shared value graph (the contract is documented safe to run outside the
79
+ // server, where a caller may hand it a live object carrying a cycle) would
80
+ // spin this walk forever; the seen-set makes each container visited once.
81
+ const seen = new WeakSet<object>();
82
+ while (stack.length > 0) {
83
+ const current = stack.pop();
84
+ if (typeof current === 'string') {
85
+ if (current.includes('\u0000')) return true;
86
+ } else if (Array.isArray(current)) {
87
+ if (seen.has(current)) continue;
88
+ seen.add(current);
89
+ for (const item of current) stack.push(item);
90
+ } else if (current !== null && typeof current === 'object') {
91
+ if (seen.has(current)) continue;
92
+ seen.add(current);
93
+ for (const [key, item] of Object.entries(current)) {
94
+ if (key.includes('\u0000')) return true;
95
+ stack.push(item);
96
+ }
97
+ }
98
+ }
99
+ return false;
100
+ }
101
+
102
+ /**
103
+ * Nesting depth of a parsed JSON value, computed iteratively (the point is to
104
+ * measure hostile depth without recursing over it). Scalars are depth 0; an
105
+ * object or array adds one level. Contracts cap opaque structures with this
106
+ * BEFORE any `JSON.stringify` runs on them — stringify is recursive and blows
107
+ * the V8 stack with a RangeError at a build-dependent depth (SL-B5), so the
108
+ * check must come first, and the contract must stay safe even when consumed
109
+ * outside the server (SDK, dashboard) where no edge middleware ran.
110
+ */
111
+ export function jsonDepthOf(value: unknown): number {
112
+ let max = 0;
113
+ const stack: Array<{ node: unknown; depth: number }> = [{ node: value, depth: 0 }];
114
+ // Same cycle guard as hasNulDeep: a live object with a back-reference must
115
+ // not make the depth walk (or the caller's later stringify) run forever.
116
+ const seen = new WeakSet<object>();
117
+ while (stack.length > 0) {
118
+ const { node, depth } = stack.pop()!;
119
+ if (depth > max) max = depth;
120
+ if (node !== null && typeof node === 'object') {
121
+ if (seen.has(node)) continue;
122
+ seen.add(node);
123
+ }
124
+ if (Array.isArray(node)) {
125
+ for (const item of node) stack.push({ node: item, depth: depth + 1 });
126
+ } else if (node !== null && typeof node === 'object') {
127
+ for (const item of Object.values(node)) stack.push({ node: item, depth: depth + 1 });
128
+ }
129
+ }
130
+ return max;
131
+ }
132
+
133
+ /**
134
+ * Depth cap for the opaque JSON objects the contract carries (metadata,
135
+ * annotation selections, form payloads). Far above any legitimate shape —
136
+ * the deepest real structure is a handful of levels — and far below both the
137
+ * server edge cap (100) and the stringify breaking depth (~5000 on
138
+ * node:22-alpine).
139
+ */
140
+ export const MAX_OPAQUE_JSON_DEPTH = 32;
141
+
142
+ /**
143
+ * The shared guard for an opaque client-supplied JSON object bound for a
144
+ * jsonb column: depth-capped first (so the later size refine may stringify
145
+ * safely), then NUL-free. Compose it onto a `z.record(...)` with `.check`
146
+ * short-circuiting: zod runs refinements in order and skips later ones once
147
+ * one fails only when `abort` is set, so both checks abort.
148
+ */
149
+ export function opaqueJsonChecks<T extends z.ZodType<Record<string, unknown>>>(schema: T): T {
150
+ return schema
151
+ .refine((v) => jsonDepthOf(v) <= MAX_OPAQUE_JSON_DEPTH, {
152
+ error: `must not nest deeper than ${MAX_OPAQUE_JSON_DEPTH} levels`,
153
+ abort: true
154
+ })
155
+ .refine((v) => !hasNulDeep(v), {
156
+ error: 'must not contain NUL characters in keys or values',
157
+ abort: true
158
+ }) as unknown as T;
159
+ }
160
+
161
+ /**
162
+ * The one free-text string builder: length-bounded and NUL-free.
163
+ * Every user-supplied name/label/title in this contract goes through it.
164
+ */
165
+ export function plainText(min: number, max: number) {
166
+ return noControlChars(z.string().min(min).max(max));
167
+ }
168
+
169
+ /**
170
+ * RFC 7231 media type: `token "/" token`, optionally followed by
171
+ * `;`-separated `token=token|quoted-string` parameters. The manifest's
172
+ * `contentType` is copied VERBATIM into the response `Content-Type` header
173
+ * when the blob is served (`files/serve.ts`), including on the anonymous
174
+ * viewer — and versions are immutable, so a committed value that the
175
+ * `Headers` constructor refuses (a non-Latin-1 character, say) was a
176
+ * permanent anonymous 500 on that asset (PLT-5, PRDCT-1358). The grammar
177
+ * check at commit is the fix; the serve-time sanitizer is the backstop for
178
+ * rows committed before it.
179
+ */
180
+ const MEDIA_TYPE_TOKEN = "[!#$%&'*+.^_`|~0-9A-Za-z-]+";
181
+ export const MEDIA_TYPE_RE = new RegExp(
182
+ `^${MEDIA_TYPE_TOKEN}\\/${MEDIA_TYPE_TOKEN}` +
183
+ `(?:[ \\t]*;[ \\t]*${MEDIA_TYPE_TOKEN}=(?:${MEDIA_TYPE_TOKEN}|"[^"\\\\\\u0000-\\u001f\\u007f]*"))*$`
184
+ );
185
+
186
+ /**
187
+ * A response header VALUE is a ByteString: setting it converts each UTF-16
188
+ * code unit and throws a TypeError on any unit above 0xFF. The token grammar
189
+ * already excludes those in unquoted positions, but a quoted parameter value
190
+ * (`charset="€"`) can carry any character the regex's negated class does not
191
+ * name — U+20AC passes the pattern and then throws at the `Headers` set. So
192
+ * the Latin-1 ceiling is a SEPARATE, explicit check: every code unit ≤ 0xFF,
193
+ * which also rejects astral characters and lone surrogates (their code units
194
+ * are all ≥ 0xD800). Without it the grammar check is not the closure PLT-5
195
+ * needs.
196
+ */
197
+ function isLatin1(value: string): boolean {
198
+ for (let i = 0; i < value.length; i++) {
199
+ if (value.charCodeAt(i) > 0xff) return false;
200
+ }
201
+ return true;
202
+ }
203
+
204
+ export function isValidMediaType(value: string): boolean {
205
+ return value.length >= 1 && value.length <= 255 && isLatin1(value) && MEDIA_TYPE_RE.test(value);
206
+ }
@@ -0,0 +1,73 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * A DEMO PASS is a link an owner mints so that one member of the workspace
5
+ * is signed in without a password, for a demonstration (the demo pass spec).
6
+ * The routes exist only while the instance's DEMO_SIGN_IN switch is on, on
7
+ * the self-hosted edition; `GET /instance` says so with `demoSignIn: true`.
8
+ * The secret is shown once, in the mint's answer, and stored nowhere.
9
+ */
10
+
11
+ /** A pass's lifetime when the mint names none: one day, in minutes. */
12
+ export const DEMO_PASS_DEFAULT_MINUTES = 1440;
13
+ /** The longest lifetime a mint may ask for: one week, in minutes. */
14
+ export const DEMO_PASS_MAX_MINUTES = 10080;
15
+
16
+ export const demoPassSchema = z.object({
17
+ id: z.string(),
18
+ /** The person the pass signs in. */
19
+ userId: z.string(),
20
+ email: z.string(),
21
+ name: z.string(),
22
+ /** The page the link lands on. */
23
+ targetPath: z.string(),
24
+ /** The owner who minted it; null once that account is gone. */
25
+ createdBy: z.string().nullable(),
26
+ createdAt: z.string(),
27
+ expiresAt: z.string(),
28
+ revokedAt: z.string().nullable(),
29
+ lastUsedAt: z.string().nullable(),
30
+ /** How many sessions the pass has opened. */
31
+ useCount: z.number().int()
32
+ });
33
+ export type DemoPass = z.infer<typeof demoPassSchema>;
34
+
35
+ export const demoPassesListSchema = z.object({ passes: z.array(demoPassSchema) });
36
+ export type DemoPassesList = z.infer<typeof demoPassesListSchema>;
37
+
38
+ /**
39
+ * The mint: a member of the caller's workspace by address, the page the link
40
+ * lands on (a same-origin path, checked by the server), and a lifetime.
41
+ */
42
+ export const demoPassMintSchema = z.object({
43
+ email: z.email(),
44
+ path: z.string().optional(),
45
+ expiresInMinutes: z.number().int().min(1).max(DEMO_PASS_MAX_MINUTES).optional()
46
+ });
47
+ export type DemoPassMint = z.infer<typeof demoPassMintSchema>;
48
+
49
+ export const demoPassMintedSchema = z.object({
50
+ pass: demoPassSchema,
51
+ /** The secret itself: shown here once, never again. */
52
+ secret: z.string(),
53
+ /** `<PUBLIC_BASE_URL>/demo#pass=<secret>&to=<path>`: the fragment never reaches a server log. */
54
+ url: z.string()
55
+ });
56
+ export type DemoPassMinted = z.infer<typeof demoPassMintedSchema>;
57
+
58
+ /**
59
+ * `POST /api/v1/auth/demo/redeem`: served by the sign-in library itself (so
60
+ * its own cookie and hooks apply), hence no route contract; these two
61
+ * shapes are the wire the SDK's `redeemDemoPass` speaks.
62
+ */
63
+ export const demoPassRedeemSchema = z.object({ pass: z.string().min(1) });
64
+ export type DemoPassRedeem = z.infer<typeof demoPassRedeemSchema>;
65
+
66
+ export const demoPassRedeemedSchema = z.object({
67
+ user: z.object({ id: z.string(), email: z.string(), name: z.string() }),
68
+ /** The pass's page: where the dashboard goes when the link names none. */
69
+ path: z.string(),
70
+ /** When the PASS expires (the session it opened keeps its own lifetime). */
71
+ expiresAt: z.string()
72
+ });
73
+ export type DemoPassRedeemed = z.infer<typeof demoPassRedeemedSchema>;
@@ -0,0 +1,30 @@
1
+ import { z } from 'zod';
2
+ import { plainText } from './common.js';
3
+
4
+ export const fileSchema = z.object({
5
+ id: z.string(),
6
+ sha256: z.string(),
7
+ sizeBytes: z.number(),
8
+ contentType: z.string(),
9
+ originalName: z.string(),
10
+ /** Null once the uploader's account was deleted (files are workspace data). */
11
+ createdBy: z.string().nullable(),
12
+ createdAt: z.string()
13
+ });
14
+ export type FileInfo = z.infer<typeof fileSchema>;
15
+
16
+ export const filesListSchema = z.object({
17
+ files: z.array(fileSchema),
18
+ nextCursor: z.string().nullable()
19
+ });
20
+
21
+ export const fileUploadedSchema = z.object({
22
+ file: fileSchema,
23
+ /** True when identical bytes already existed for this workspace. */
24
+ deduplicated: z.boolean()
25
+ });
26
+
27
+ export const fileUploadQuerySchema = z.object({
28
+ /** Original filename (the body is the raw bytes). */
29
+ name: plainText(1, 255)
30
+ });
@@ -0,0 +1,71 @@
1
+ import { z } from 'zod';
2
+ import { KNOWN_AUTH_METHODS } from '../seams.js';
3
+ import { toolEntitlementsSchema } from '../entitlements.js';
4
+
5
+ /**
6
+ * GET /api/v1/instance — unauthenticated, cacheable discovery. This is what
7
+ * CLIs and MCP clients read to route: which auth methods exist, whether
8
+ * first-boot setup is still pending, and what the instance is.
9
+ */
10
+ export const instanceInfoSchema = z.object({
11
+ name: z.string(),
12
+ instanceId: z.string().nullable(),
13
+ edition: z.string(),
14
+ version: z.string(),
15
+ apiVersion: z.literal('v1'),
16
+ setupRequired: z.boolean(),
17
+ auth: z.object({
18
+ // Open enum (ADR 003): the enum documents the known vocabulary, the
19
+ // string branch keeps older clients parsing newer instances. Clients
20
+ // ignore entries they do not recognize.
21
+ methods: z.array(z.enum(KNOWN_AUTH_METHODS).or(z.string())),
22
+ passwordReset: z.boolean(),
23
+ emailChange: z.boolean(),
24
+ twoFactor: z.boolean(),
25
+ /**
26
+ * Hub SSO client hints (cloud edition ONLY — absent on oss, where the
27
+ * discovery wire shape stays byte-identical). The hint cookie is a
28
+ * dashboard-side HINT for the silent auto-connect (attempt vs don't),
29
+ * NEVER a security input: the hub sets it, this tool reads it
30
+ * client-side and clears it on logout / login_required
31
+ * (internal/federation.md, cross-repo contract).
32
+ */
33
+ sso: z
34
+ .object({
35
+ hintCookieName: z.string(),
36
+ hintCookieDomain: z.string()
37
+ })
38
+ .optional()
39
+ }),
40
+ features: z.object({
41
+ mcp: z.boolean(),
42
+ oauth: z.boolean(),
43
+ files: z.boolean()
44
+ }),
45
+ /**
46
+ * `true` while the operator's DEMO_SIGN_IN switch is on (self-hosted
47
+ * edition only): demo links sign people in here, and the dashboard shows
48
+ * every signed-in person a banner saying so. ABSENT otherwise, never false,
49
+ * so an instance without the switch serves the same discovery bytes as
50
+ * before it existed.
51
+ */
52
+ demoSignIn: z.boolean().optional(),
53
+ /**
54
+ * `true` when this cloud instance is a RESTRICTED tool (the operator's
55
+ * TOOL_RESTRICTED switch, PRDCT-2947): organizations that open it are
56
+ * granted by Antasphere, and `POST /workspaces` refuses here. A CLI reads
57
+ * it to say so before offering to create one. ABSENT otherwise, never
58
+ * false, so the self-hosted discovery bytes are unchanged.
59
+ */
60
+ restricted: z.boolean().optional(),
61
+ /**
62
+ * What this version of the tool declares for the billing rail (its priced
63
+ * actions with their default credits, its limits and features per tier):
64
+ * the hub seeds its price book and its plan entitlements from it, staff
65
+ * read what a version declares, and a client reads the limit it will be
66
+ * held to (the CLI's upfront refusal with the right number). Optional on
67
+ * the wire so a client keeps parsing an instance from before the rail.
68
+ */
69
+ entitlements: toolEntitlementsSchema.optional()
70
+ });
71
+ export type InstanceInfo = z.infer<typeof instanceInfoSchema>;