@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.
- package/LICENSE +82 -0
- package/dist/define.d.ts +177 -0
- package/dist/define.d.ts.map +1 -0
- package/dist/define.js +35 -0
- package/dist/define.js.map +1 -0
- package/dist/entitlements.d.ts +486 -0
- package/dist/entitlements.d.ts.map +1 -0
- package/dist/entitlements.js +344 -0
- package/dist/entitlements.js.map +1 -0
- package/dist/identity.d.ts +56 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +2 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/routes/index.d.ts +5403 -0
- package/dist/routes/index.d.ts.map +1 -0
- package/dist/routes/index.js +940 -0
- package/dist/routes/index.js.map +1 -0
- package/dist/schemas/api-keys.d.ts +54 -0
- package/dist/schemas/api-keys.d.ts.map +1 -0
- package/dist/schemas/api-keys.js +48 -0
- package/dist/schemas/api-keys.js.map +1 -0
- package/dist/schemas/audit.d.ts +74 -0
- package/dist/schemas/audit.d.ts.map +1 -0
- package/dist/schemas/audit.js +65 -0
- package/dist/schemas/audit.js.map +1 -0
- package/dist/schemas/break-glass.d.ts +42 -0
- package/dist/schemas/break-glass.d.ts.map +1 -0
- package/dist/schemas/break-glass.js +38 -0
- package/dist/schemas/break-glass.js.map +1 -0
- package/dist/schemas/cli-auth.d.ts +59 -0
- package/dist/schemas/cli-auth.d.ts.map +1 -0
- package/dist/schemas/cli-auth.js +68 -0
- package/dist/schemas/cli-auth.js.map +1 -0
- package/dist/schemas/common.d.ts +96 -0
- package/dist/schemas/common.d.ts.map +1 -0
- package/dist/schemas/common.js +205 -0
- package/dist/schemas/common.js.map +1 -0
- package/dist/schemas/demo-passes.d.ts +90 -0
- package/dist/schemas/demo-passes.d.ts.map +1 -0
- package/dist/schemas/demo-passes.js +60 -0
- package/dist/schemas/demo-passes.js.map +1 -0
- package/dist/schemas/files.d.ts +39 -0
- package/dist/schemas/files.d.ts.map +1 -0
- package/dist/schemas/files.js +26 -0
- package/dist/schemas/files.js.map +1 -0
- package/dist/schemas/instance.d.ts +58 -0
- package/dist/schemas/instance.d.ts.map +1 -0
- package/dist/schemas/instance.js +70 -0
- package/dist/schemas/instance.js.map +1 -0
- package/dist/schemas/invitations.d.ts +100 -0
- package/dist/schemas/invitations.d.ts.map +1 -0
- package/dist/schemas/invitations.js +55 -0
- package/dist/schemas/invitations.js.map +1 -0
- package/dist/schemas/me.d.ts +81 -0
- package/dist/schemas/me.d.ts.map +1 -0
- package/dist/schemas/me.js +178 -0
- package/dist/schemas/me.js.map +1 -0
- package/dist/schemas/members.d.ts +60 -0
- package/dist/schemas/members.d.ts.map +1 -0
- package/dist/schemas/members.js +43 -0
- package/dist/schemas/members.js.map +1 -0
- package/dist/schemas/projects.d.ts +232 -0
- package/dist/schemas/projects.d.ts.map +1 -0
- package/dist/schemas/projects.js +140 -0
- package/dist/schemas/projects.js.map +1 -0
- package/dist/schemas/scope.d.ts +14 -0
- package/dist/schemas/scope.d.ts.map +1 -0
- package/dist/schemas/scope.js +5 -0
- package/dist/schemas/scope.js.map +1 -0
- package/dist/schemas/setup.d.ts +28 -0
- package/dist/schemas/setup.d.ts.map +1 -0
- package/dist/schemas/setup.js +28 -0
- package/dist/schemas/setup.js.map +1 -0
- package/dist/schemas/sso-connect.d.ts +30 -0
- package/dist/schemas/sso-connect.d.ts.map +1 -0
- package/dist/schemas/sso-connect.js +38 -0
- package/dist/schemas/sso-connect.js.map +1 -0
- package/dist/schemas/sso-logout.d.ts +17 -0
- package/dist/schemas/sso-logout.d.ts.map +1 -0
- package/dist/schemas/sso-logout.js +16 -0
- package/dist/schemas/sso-logout.js.map +1 -0
- package/dist/schemas/teams.d.ts +100 -0
- package/dist/schemas/teams.d.ts.map +1 -0
- package/dist/schemas/teams.js +84 -0
- package/dist/schemas/teams.js.map +1 -0
- package/dist/schemas/workspaces.d.ts +99 -0
- package/dist/schemas/workspaces.d.ts.map +1 -0
- package/dist/schemas/workspaces.js +96 -0
- package/dist/schemas/workspaces.js.map +1 -0
- package/dist/seams.d.ts +185 -0
- package/dist/seams.d.ts.map +1 -0
- package/dist/seams.js +37 -0
- package/dist/seams.js.map +1 -0
- package/dist/wire.d.ts +101 -0
- package/dist/wire.d.ts.map +1 -0
- package/dist/wire.js +161 -0
- package/dist/wire.js.map +1 -0
- package/package.json +41 -3
- package/src/define.ts +64 -0
- package/src/entitlements.ts +527 -0
- package/src/identity.ts +49 -0
- package/src/index.ts +38 -0
- package/src/routes/index.ts +1146 -0
- package/src/schemas/api-keys.ts +53 -0
- package/src/schemas/audit.ts +74 -0
- package/src/schemas/break-glass.ts +46 -0
- package/src/schemas/cli-auth.ts +78 -0
- package/src/schemas/common.ts +206 -0
- package/src/schemas/demo-passes.ts +73 -0
- package/src/schemas/files.ts +30 -0
- package/src/schemas/instance.ts +71 -0
- package/src/schemas/invitations.ts +66 -0
- package/src/schemas/me.ts +184 -0
- package/src/schemas/members.ts +53 -0
- package/src/schemas/projects.ts +174 -0
- package/src/schemas/scope.ts +18 -0
- package/src/schemas/setup.ts +31 -0
- package/src/schemas/sso-connect.ts +40 -0
- package/src/schemas/sso-logout.ts +17 -0
- package/src/schemas/teams.ts +101 -0
- package/src/schemas/workspaces.ts +109 -0
- package/src/seams.ts +202 -0
- package/src/wire.ts +246 -0
- package/test/define.test.ts +59 -0
- package/test/hub-wire.test.ts +114 -0
- 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>;
|