@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,101 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { cursorPageQuerySchema, noControlChars, plainText, workspaceRoleSchema } from './common.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Teams: named groups of the workspace's people, on BOTH editions with the
|
|
6
|
+
* same routes and screens; only the source differs (PRDCT-2813). On the
|
|
7
|
+
* self-hosted edition, and in a cloud-local workspace, owners and admins
|
|
8
|
+
* make and shape them here; in a hub-origin workspace they are the hub's
|
|
9
|
+
* projection, read here and managed on the account site (every write answers
|
|
10
|
+
* `hub_managed`). Every non-guest member reads the teams and a team's
|
|
11
|
+
* members. A team holds the workspace's MEMBERSHIPS, so it can never hold an
|
|
12
|
+
* outsider, and a person leaves every team with their membership.
|
|
13
|
+
*
|
|
14
|
+
* The shapes mirror the hub's own teams contract (its `schemas/teams.ts`), so
|
|
15
|
+
* the two Teams pages read the same; a person is named by their user id or
|
|
16
|
+
* their email, as on this tool's project routes.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** A team's slug: lowercase words joined by single dashes, unique within a workspace. */
|
|
20
|
+
export const teamSlugSchema = z
|
|
21
|
+
.string()
|
|
22
|
+
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'lowercase letters and digits, joined by single dashes')
|
|
23
|
+
.min(1)
|
|
24
|
+
.max(60);
|
|
25
|
+
|
|
26
|
+
export const teamSchema = z.object({
|
|
27
|
+
id: z.string(),
|
|
28
|
+
slug: z.string(),
|
|
29
|
+
name: z.string(),
|
|
30
|
+
membersCount: z.number().int(),
|
|
31
|
+
/** The caller belongs to this team. */
|
|
32
|
+
isMember: z.boolean(),
|
|
33
|
+
/**
|
|
34
|
+
* The hub's team id when the team is the account site's projection (the
|
|
35
|
+
* Manage link opens it there); null for a team the tool owns.
|
|
36
|
+
*/
|
|
37
|
+
hubTeamId: z.string().nullable(),
|
|
38
|
+
createdAt: z.string(),
|
|
39
|
+
updatedAt: z.string()
|
|
40
|
+
});
|
|
41
|
+
export type Team = z.infer<typeof teamSchema>;
|
|
42
|
+
|
|
43
|
+
export const teamsListSchema = z.object({
|
|
44
|
+
teams: z.array(teamSchema),
|
|
45
|
+
nextCursor: z.string().nullable()
|
|
46
|
+
});
|
|
47
|
+
export type TeamsList = z.infer<typeof teamsListSchema>;
|
|
48
|
+
|
|
49
|
+
export const teamsListQuerySchema = cursorPageQuerySchema;
|
|
50
|
+
|
|
51
|
+
export const teamCreateSchema = z.object({
|
|
52
|
+
name: plainText(1, 80),
|
|
53
|
+
/** Absent: derived from the name (lowercased, non-alphanumerics to dashes, cut to 60). */
|
|
54
|
+
slug: teamSlugSchema.optional()
|
|
55
|
+
});
|
|
56
|
+
export type TeamCreate = z.infer<typeof teamCreateSchema>;
|
|
57
|
+
|
|
58
|
+
export const teamUpdateSchema = z
|
|
59
|
+
.object({
|
|
60
|
+
name: plainText(1, 80).optional(),
|
|
61
|
+
slug: teamSlugSchema.optional()
|
|
62
|
+
})
|
|
63
|
+
.refine((b) => b.name !== undefined || b.slug !== undefined, { error: 'nothing to change' });
|
|
64
|
+
export type TeamUpdate = z.infer<typeof teamUpdateSchema>;
|
|
65
|
+
|
|
66
|
+
export const teamMemberSchema = z.object({
|
|
67
|
+
userId: z.string(),
|
|
68
|
+
email: z.string(),
|
|
69
|
+
name: z.string(),
|
|
70
|
+
/** The person's role in the WORKSPACE (a team has no roles of its own). */
|
|
71
|
+
role: workspaceRoleSchema,
|
|
72
|
+
isActive: z.boolean(),
|
|
73
|
+
addedAt: z.string()
|
|
74
|
+
});
|
|
75
|
+
export type TeamMemberInfo = z.infer<typeof teamMemberSchema>;
|
|
76
|
+
|
|
77
|
+
export const teamMembersListSchema = z.object({
|
|
78
|
+
members: z.array(teamMemberSchema),
|
|
79
|
+
nextCursor: z.string().nullable()
|
|
80
|
+
});
|
|
81
|
+
export type TeamMembersList = z.infer<typeof teamMembersListSchema>;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Seat one of the workspace's OWN active non-guest members, named by user id
|
|
85
|
+
* or by email: exactly one of the two. Nothing is invited or minted here.
|
|
86
|
+
*/
|
|
87
|
+
export const teamMemberAddSchema = z
|
|
88
|
+
.object({
|
|
89
|
+
userId: plainText(1, 200).optional(),
|
|
90
|
+
email: z.email().optional()
|
|
91
|
+
})
|
|
92
|
+
.refine((v) => (v.userId === undefined) !== (v.email === undefined), {
|
|
93
|
+
error: 'exactly one of userId or email is required'
|
|
94
|
+
});
|
|
95
|
+
export type TeamMemberAdd = z.infer<typeof teamMemberAddSchema>;
|
|
96
|
+
|
|
97
|
+
/** `{id}` the team (a uuid); `{userId}` Better Auth's text id: bounded free text, no control characters. */
|
|
98
|
+
export const teamMemberParamsSchema = z.object({
|
|
99
|
+
id: z.uuid(),
|
|
100
|
+
userId: noControlChars(z.string().min(1).max(200))
|
|
101
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { plainText } from './common.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A workspace's look: one of the dashboard's theme keys and one pattern key
|
|
6
|
+
* of the brand's library. A fact of the WORKSPACE (every member sees the
|
|
7
|
+
* same tile), kept as two short slugs the server does not interpret: the
|
|
8
|
+
* dashboard owns the catalogue and falls back to its defaults on a key it
|
|
9
|
+
* does not know, so retiring a theme never breaks a workspace. null = the
|
|
10
|
+
* dashboard's default for this workspace.
|
|
11
|
+
*/
|
|
12
|
+
const lookKey = z
|
|
13
|
+
.string()
|
|
14
|
+
.regex(/^[a-z][a-z0-9-]{0,31}$/, 'a short lowercase key')
|
|
15
|
+
.nullable();
|
|
16
|
+
/**
|
|
17
|
+
* How much of the field's gradient reaches the page, and how much grain sits
|
|
18
|
+
* on it: 0..1, the workspace's too (they were each browser's until
|
|
19
|
+
* 2026-09-19, so two members never saw the same page). null = the brand's
|
|
20
|
+
* constant.
|
|
21
|
+
*/
|
|
22
|
+
const lookLevel = z.number().min(0).max(1).nullable();
|
|
23
|
+
export const workspaceLookSchema = z.object({
|
|
24
|
+
theme: lookKey,
|
|
25
|
+
pattern: lookKey,
|
|
26
|
+
field: lookLevel,
|
|
27
|
+
grain: lookLevel
|
|
28
|
+
});
|
|
29
|
+
export type WorkspaceLook = z.infer<typeof workspaceLookSchema>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* POST /api/v1/workspaces — a signed-in person creates ANOTHER workspace from
|
|
33
|
+
* inside the product (PRDCT-2444 self-hosted, PRDCT-2443 cloud). One route,
|
|
34
|
+
* one body, one answer on both editions; the dashboard keys off
|
|
35
|
+
* `/me.canCreateWorkspace` and never sniffs the edition.
|
|
36
|
+
*
|
|
37
|
+
* - Self-hosted: the workspace is created locally with the caller as its
|
|
38
|
+
* active owner, under the operator's per-person cap
|
|
39
|
+
* (`MAX_WORKSPACES_PER_USER`).
|
|
40
|
+
* - Cloud: the organization is created at Antasphere AS THE CALLER, then
|
|
41
|
+
* projected locally before the answer — `workspace.id` is always the
|
|
42
|
+
* LOCAL workspace id, the one `X-Workspace-Id` selects.
|
|
43
|
+
*
|
|
44
|
+
* Sessions only: API keys and OAuth bearers are refused (the route is
|
|
45
|
+
* deliberately absent from the machine scope allowlist). The name is trimmed
|
|
46
|
+
* server-side; a name that is blank once trimmed answers 400. The look the
|
|
47
|
+
* person picked in the dialog rides along, so the workspace is born with it
|
|
48
|
+
* on every member's screen.
|
|
49
|
+
*/
|
|
50
|
+
export const workspaceCreateSchema = z.object({
|
|
51
|
+
name: plainText(1, 120),
|
|
52
|
+
look: workspaceLookSchema.partial().optional()
|
|
53
|
+
});
|
|
54
|
+
export type WorkspaceCreate = z.infer<typeof workspaceCreateSchema>;
|
|
55
|
+
|
|
56
|
+
export const workspaceCreatedSchema = z.object({
|
|
57
|
+
workspace: z.object({
|
|
58
|
+
/** The LOCAL workspace id — pass it as `X-Workspace-Id` to work in it. */
|
|
59
|
+
id: z.string(),
|
|
60
|
+
name: z.string(),
|
|
61
|
+
look: workspaceLookSchema
|
|
62
|
+
})
|
|
63
|
+
});
|
|
64
|
+
export type WorkspaceCreated = z.infer<typeof workspaceCreatedSchema>;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* PUT /api/v1/me/default-workspace — the workspace a request naming none
|
|
68
|
+
* resolves to, chosen by the person for themselves (PRDCT-2815). `null`
|
|
69
|
+
* clears the choice: the oldest active membership answers again. On the
|
|
70
|
+
* cloud edition the default is a setting of the person's Antasphere account,
|
|
71
|
+
* projected here at every sign-in: the route answers 403 `hub_managed` with
|
|
72
|
+
* the account site to set it on.
|
|
73
|
+
*/
|
|
74
|
+
export const defaultWorkspaceSetSchema = z.object({
|
|
75
|
+
workspaceId: z.string().uuid().nullable()
|
|
76
|
+
});
|
|
77
|
+
export type DefaultWorkspaceSet = z.infer<typeof defaultWorkspaceSetSchema>;
|
|
78
|
+
|
|
79
|
+
export const defaultWorkspaceSchema = z.object({
|
|
80
|
+
/** The LOCAL id of the person's default workspace, or null when none is chosen. */
|
|
81
|
+
defaultWorkspaceId: z.string().nullable()
|
|
82
|
+
});
|
|
83
|
+
export type DefaultWorkspace = z.infer<typeof defaultWorkspaceSchema>;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* PATCH /api/v1/workspace — the ACTIVE workspace's own settings (its name,
|
|
87
|
+
* its look), by an owner or an admin of it, from a browser session. The
|
|
88
|
+
* route names no id: a request works in exactly one workspace (ADR 014),
|
|
89
|
+
* and that is the one it edits. On the cloud edition a hub-origin
|
|
90
|
+
* workspace's NAME is the hub's (P7): a rename answers 403 `hub_managed`
|
|
91
|
+
* with the console to do it at; its look stays a local fact and is
|
|
92
|
+
* accepted.
|
|
93
|
+
*/
|
|
94
|
+
export const workspaceUpdateSchema = z
|
|
95
|
+
.object({
|
|
96
|
+
name: plainText(1, 120).optional(),
|
|
97
|
+
look: workspaceLookSchema.partial().optional()
|
|
98
|
+
})
|
|
99
|
+
.refine((b) => b.name !== undefined || b.look !== undefined, { message: 'nothing to change' });
|
|
100
|
+
export type WorkspaceUpdate = z.infer<typeof workspaceUpdateSchema>;
|
|
101
|
+
|
|
102
|
+
export const workspaceUpdatedSchema = z.object({
|
|
103
|
+
workspace: z.object({
|
|
104
|
+
id: z.string(),
|
|
105
|
+
name: z.string(),
|
|
106
|
+
look: workspaceLookSchema
|
|
107
|
+
})
|
|
108
|
+
});
|
|
109
|
+
export type WorkspaceUpdated = z.infer<typeof workspaceUpdatedSchema>;
|
package/src/seams.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The central-rail seam: three small interfaces wired through the boot-time
|
|
3
|
+
* module registry. The central account/credits service is NOT part of the
|
|
4
|
+
* template — these interfaces are the socket it plugs into later with zero
|
|
5
|
+
* refactor. Template defaults: LocalIdentityProvider, AllowAllEntitlements,
|
|
6
|
+
* a no-op UsageSink.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Structural Headers so the contract stays lib-agnostic (works in DOM and Node). */
|
|
10
|
+
export interface HeaderReader {
|
|
11
|
+
get(name: string): string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RequestContext {
|
|
15
|
+
/** Raw request headers (credential material lives here). */
|
|
16
|
+
headers: HeaderReader;
|
|
17
|
+
path: string;
|
|
18
|
+
method: string;
|
|
19
|
+
requestId: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The universal per-request workspace selector (user-scoped credential
|
|
24
|
+
* model). EVERY credential kind — session, API key, OAuth bearer — MAY send
|
|
25
|
+
* it to name which of the caller's workspaces this request targets; the
|
|
26
|
+
* resolvers verify an ACTIVE membership of that workspace and resolve to
|
|
27
|
+
* null otherwise (fail closed — no oracle about the workspace's existence).
|
|
28
|
+
* Absent, the user's DEFAULT membership is used, else the deterministic
|
|
29
|
+
* fallback (oldest active membership). The one exception is a PINNED API
|
|
30
|
+
* key (an optional least-privilege pin, and the grandfathered binding of
|
|
31
|
+
* keys minted under the old per-workspace model): it always resolves its
|
|
32
|
+
* pinned workspace, and a header naming a DIFFERENT one is rejected
|
|
33
|
+
* outright (403 workspace_mismatch).
|
|
34
|
+
*/
|
|
35
|
+
export const ACTIVE_WORKSPACE_HEADER = 'x-workspace-id';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The resolved caller of ONE request. A credential identifies a USER; the
|
|
39
|
+
* Principal is still scoped to exactly ONE workspace per request (ADR 014):
|
|
40
|
+
* `workspaceId` is the workspace this request SELECTED via
|
|
41
|
+
* {@link ACTIVE_WORKSPACE_HEADER} (or the user's default), authorized
|
|
42
|
+
* against the caller's own live memberships. A user's OTHER memberships are
|
|
43
|
+
* deliberately not represented here; a request never spans workspaces.
|
|
44
|
+
*/
|
|
45
|
+
export interface Principal {
|
|
46
|
+
userId: string;
|
|
47
|
+
email: string;
|
|
48
|
+
name: string;
|
|
49
|
+
workspaceId: string;
|
|
50
|
+
role: 'owner' | 'admin' | 'member';
|
|
51
|
+
/**
|
|
52
|
+
* How the resolved membership came to exist (D2, internal/federation.md §P6):
|
|
53
|
+
* 'local' = ordinary membership (setup, workspace invitation), 'hub' = a
|
|
54
|
+
* hub-org projection (cloud SSO), 'guest' = an external per-resource
|
|
55
|
+
* collaborator minted by the claim path. Origin is a CAPABILITY axis, not
|
|
56
|
+
* a role: a guest resolves like any member but is refused resource creation
|
|
57
|
+
* and workspace-level surfaces (requireNonGuest) on BOTH editions — their
|
|
58
|
+
* access is the per-resource grant (ADR 013), which stays untouched. Every
|
|
59
|
+
* credential path (session, API key, OAuth bearer) reads it live from the
|
|
60
|
+
* membership row backing the request's workspace.
|
|
61
|
+
*/
|
|
62
|
+
origin: 'local' | 'hub' | 'guest';
|
|
63
|
+
via: 'session' | 'api_key' | 'oauth';
|
|
64
|
+
/** Scope allowlist for machine principals; null = full role-based (session). */
|
|
65
|
+
scopes: ReadonlySet<string> | null;
|
|
66
|
+
apiKeyId?: string;
|
|
67
|
+
/** Expiry of the presented API key (ISO 8601) — absent for non-expiring keys. */
|
|
68
|
+
apiKeyExpiresAt?: string;
|
|
69
|
+
/** Central account id — undefined in local mode. */
|
|
70
|
+
accountRef?: string;
|
|
71
|
+
/**
|
|
72
|
+
* The credential itself fixed the workspace (an API key pinned to one):
|
|
73
|
+
* the request's selector was checked against the pin, and no default of
|
|
74
|
+
* the person ever moves the request elsewhere. Absent on every credential
|
|
75
|
+
* whose workspace the request selected, or the person's default chose.
|
|
76
|
+
*/
|
|
77
|
+
pinned?: true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Sign-in methods an instance can advertise in discovery. The set is OPEN
|
|
82
|
+
* (ADR 003 reserved this): clients must IGNORE entries they do not
|
|
83
|
+
* recognize, so an edition can add a method — like the cloud edition's
|
|
84
|
+
* 'antasphere' hub SSO — without breaking older SDKs/CLIs. This list is the
|
|
85
|
+
* known vocabulary, not a closed enum.
|
|
86
|
+
*/
|
|
87
|
+
export const KNOWN_AUTH_METHODS = [
|
|
88
|
+
'password',
|
|
89
|
+
'email-otp',
|
|
90
|
+
'google',
|
|
91
|
+
'api-key',
|
|
92
|
+
'oauth',
|
|
93
|
+
'antasphere'
|
|
94
|
+
] as const;
|
|
95
|
+
export type KnownAuthMethod = (typeof KNOWN_AUTH_METHODS)[number];
|
|
96
|
+
|
|
97
|
+
export interface InstanceAuthDescriptor {
|
|
98
|
+
/** Open set — known values in {@link KNOWN_AUTH_METHODS}; ignore unknown entries. */
|
|
99
|
+
methods: Array<KnownAuthMethod | (string & {})>;
|
|
100
|
+
/** Self-serve password reset is available (requires a configured email driver). */
|
|
101
|
+
passwordReset: boolean;
|
|
102
|
+
/** Self-serve email change is available (requires a configured email driver). */
|
|
103
|
+
emailChange: boolean;
|
|
104
|
+
/** Opt-in per-user 2FA (TOTP + backup codes) is available on this instance. */
|
|
105
|
+
twoFactor: boolean;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface IdentityProvider {
|
|
109
|
+
/** Resolve a request to a live principal (fresh membership check) or null. */
|
|
110
|
+
resolve(ctx: RequestContext): Promise<Principal | null>;
|
|
111
|
+
/** Feeds the instance discovery endpoint. */
|
|
112
|
+
describe(): InstanceAuthDescriptor;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface MeteredAction {
|
|
116
|
+
/** The action key, e.g. 'files.upload' (the route's `meter.key`, namespaced by the tool's price book). */
|
|
117
|
+
key: string;
|
|
118
|
+
quantity: number;
|
|
119
|
+
unit: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type EntitlementDecision = { allowed: true } | { allowed: false; reason: string };
|
|
123
|
+
|
|
124
|
+
/** Per-principal general-API request quota, enforced by the server's quota middleware. */
|
|
125
|
+
export interface RequestQuota {
|
|
126
|
+
/** Sustained allowance per principal per minute. 0 = unlimited (the limiter is skipped). */
|
|
127
|
+
perMinute: number;
|
|
128
|
+
/** Spike cap per principal per second inside the minute window. 0 = no burst smoothing. */
|
|
129
|
+
burstPerSecond: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface EntitlementService {
|
|
133
|
+
check(
|
|
134
|
+
principal: Principal,
|
|
135
|
+
action: MeteredAction,
|
|
136
|
+
ctx?: Record<string, unknown>
|
|
137
|
+
): Promise<EntitlementDecision>;
|
|
138
|
+
/**
|
|
139
|
+
* Request-rate quota for this principal. The template default returns the
|
|
140
|
+
* instance-level env caps for everyone; a plan-aware edition varies it per
|
|
141
|
+
* account/plan. The quota is enforced per authenticated principal identity
|
|
142
|
+
* (API key id / OAuth subject / session user id), never per client header.
|
|
143
|
+
*/
|
|
144
|
+
getRequestQuota(principal: Principal): Promise<RequestQuota>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* One metered action, as the tool emits it and the hub ingests it (the
|
|
149
|
+
* billing rail spec, §5 `POST /usage/events`). At-least-once from the tool;
|
|
150
|
+
* exactly-once at the hub by `id`. Paid by the account, reported per user.
|
|
151
|
+
*/
|
|
152
|
+
export interface UsageEvent {
|
|
153
|
+
/** ULID — the idempotency key; receivers dedupe on it (at-least-once emission). */
|
|
154
|
+
id: string;
|
|
155
|
+
/** The action key (`meter` is its historical name; the two carry the same value). */
|
|
156
|
+
meter: string;
|
|
157
|
+
actionKey: string;
|
|
158
|
+
quantity: number;
|
|
159
|
+
unit: string;
|
|
160
|
+
occurredAt: string; // ISO 8601
|
|
161
|
+
workspaceId: string;
|
|
162
|
+
/** The paying account (the workspace's central account id). */
|
|
163
|
+
accountRef?: string;
|
|
164
|
+
/** The reported user; null when the actor is a resource owner the tool could not name. */
|
|
165
|
+
userId: string | null;
|
|
166
|
+
/** How the action arrived: a session (the dashboard), an API key (the CLI), an OAuth client (an MCP connector). */
|
|
167
|
+
via: 'session' | 'api_key' | 'oauth';
|
|
168
|
+
/** The resource the handler recorded (its audit `resourceType` / `resourceId`), when it recorded one. */
|
|
169
|
+
resourceType?: string;
|
|
170
|
+
resourceId?: string;
|
|
171
|
+
source: {
|
|
172
|
+
instanceId: string;
|
|
173
|
+
edition: string;
|
|
174
|
+
version: string;
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface UsageSink {
|
|
179
|
+
emit(event: UsageEvent): Promise<void>;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* What a downstream says about a batch it took: the events it did NOT post
|
|
184
|
+
* because the hub would refuse them on their date (PRDCT-2644), each with
|
|
185
|
+
* its reason. The worker sends those to the held queue instead of leaving
|
|
186
|
+
* them counted as rejections at the hub; an empty list, or no outcome at
|
|
187
|
+
* all, means the whole batch was handled.
|
|
188
|
+
*/
|
|
189
|
+
export interface UsageBatchOutcome {
|
|
190
|
+
held: ReadonlyArray<{ id: string; reason: string }>;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Where the durable queue hands the events it drained: the no-op locally,
|
|
195
|
+
* the hub poster on cloud. A downstream that takes whole batches gets them
|
|
196
|
+
* as the worker drained them (fifty at most); one that only takes single
|
|
197
|
+
* events is called once per event. A batch downstream may answer an outcome
|
|
198
|
+
* naming the events it held back.
|
|
199
|
+
*/
|
|
200
|
+
export interface UsageDownstream extends UsageSink {
|
|
201
|
+
emitBatch?(events: readonly UsageEvent[]): Promise<void | UsageBatchOutcome>;
|
|
202
|
+
}
|
package/src/wire.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import {
|
|
3
|
+
BILLING_PLANS,
|
|
4
|
+
USAGE_CHECK_REASONS,
|
|
5
|
+
USAGE_EVENT_MAX_FUTURE_MS,
|
|
6
|
+
USAGE_EVENT_MAX_PAST_MS,
|
|
7
|
+
USAGE_EVENTS_BATCH_MAX,
|
|
8
|
+
USAGE_REJECT_REASONS,
|
|
9
|
+
USAGE_ROUTE_ERRORS,
|
|
10
|
+
USAGE_WRITE_SCOPE,
|
|
11
|
+
entitlementProfileSchema,
|
|
12
|
+
usageCheckRequestSchema,
|
|
13
|
+
usageCheckSchema,
|
|
14
|
+
usageEventOccurrenceIssue,
|
|
15
|
+
usageEventSchema,
|
|
16
|
+
usageEventsBatchSchema,
|
|
17
|
+
usageIngestResultSchema
|
|
18
|
+
} from './entitlements.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The chassis's side of the hub–tool wire check (PRDCT-2677). The hub owns
|
|
22
|
+
* the messages the tools exchange with it and publishes them as ONE
|
|
23
|
+
* generated snapshot, `packages/contract/wire/hub-tool-messages.json` of the
|
|
24
|
+
* hub, built by its `packages/contract/src/wire.ts`. The block below, from
|
|
25
|
+
* `WIRE_SCHEMA_KEYS` to `buildWireSnapshot`, is the chassis's copy of that
|
|
26
|
+
* builder, kept VERBATIM so both sides compute the same picture: the same
|
|
27
|
+
* JSON Schema export, the same probe verdicts, the same occurrence table.
|
|
28
|
+
* The hub's inputs (its offsets and probe payloads) and its definitions are
|
|
29
|
+
* NOT copied: the inputs are read back from the hub's file
|
|
30
|
+
* (`wireInputsOf`), the definitions are this package's own copies
|
|
31
|
+
* (`CHASSIS_WIRE_DEFINITIONS`, from `entitlements.ts`).
|
|
32
|
+
*
|
|
33
|
+
* `compareWireSnapshots` names every difference by its dotted path;
|
|
34
|
+
* `scripts/hub-wire-check.mjs` (`pnpm --filter @antasphere/chassis-contract
|
|
35
|
+
* wire:check`, the `hub-wire` CI job against the hub's `dev`) fails on any.
|
|
36
|
+
* The check runs one way: the hub changes first, the chassis follows.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
// ── Copied VERBATIM from the hub's packages/contract/src/wire.ts ──────────
|
|
40
|
+
|
|
41
|
+
/** The keys of the six shapes, the same on both sides. */
|
|
42
|
+
export const WIRE_SCHEMA_KEYS = [
|
|
43
|
+
'usageEvent',
|
|
44
|
+
'usageEventsBatch',
|
|
45
|
+
'usageIngestResult',
|
|
46
|
+
'usageEntitlements',
|
|
47
|
+
'usageCheckRequest',
|
|
48
|
+
'usageCheck'
|
|
49
|
+
] as const;
|
|
50
|
+
export type WireSchemaKey = (typeof WIRE_SCHEMA_KEYS)[number];
|
|
51
|
+
|
|
52
|
+
export interface WireConstants {
|
|
53
|
+
USAGE_WRITE_SCOPE: string;
|
|
54
|
+
BILLING_PLANS: readonly string[];
|
|
55
|
+
USAGE_EVENTS_BATCH_MAX: number;
|
|
56
|
+
USAGE_EVENT_MAX_PAST_MS: number;
|
|
57
|
+
USAGE_EVENT_MAX_FUTURE_MS: number;
|
|
58
|
+
USAGE_CHECK_REASONS: readonly string[];
|
|
59
|
+
USAGE_REJECT_REASONS: readonly string[];
|
|
60
|
+
USAGE_ROUTE_ERRORS: Readonly<Record<string, number>>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface WireProbe {
|
|
64
|
+
name: string;
|
|
65
|
+
input: unknown;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface WireProbeResult extends WireProbe {
|
|
69
|
+
/** Whether the schema accepts the input. */
|
|
70
|
+
ok: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface WireOccurrence {
|
|
74
|
+
/** The event's `occurredAt` minus the receipt time, in milliseconds. */
|
|
75
|
+
offsetMs: number;
|
|
76
|
+
issue: { path: 'occurredAt'; message: string } | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** What one side feeds the builder: its own definitions. */
|
|
80
|
+
export interface WireDefinitions {
|
|
81
|
+
constants: WireConstants;
|
|
82
|
+
schemas: Record<WireSchemaKey, z.ZodType>;
|
|
83
|
+
occurrenceIssue: (occurredAt: Date, receivedAt: Date) => { path: 'occurredAt'; message: string } | null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The inputs the snapshot is computed on: the hub's, read back by the chassis from the hub's file. */
|
|
87
|
+
export interface WireInputs {
|
|
88
|
+
occurrenceOffsetsMs: readonly number[];
|
|
89
|
+
probes: Record<WireSchemaKey, readonly WireProbe[]>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface HubToolWireSnapshot {
|
|
93
|
+
v: 1;
|
|
94
|
+
constants: WireConstants;
|
|
95
|
+
schemas: Record<WireSchemaKey, unknown>;
|
|
96
|
+
occurrence: WireOccurrence[];
|
|
97
|
+
probes: Record<WireSchemaKey, WireProbeResult[]>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A fixed receipt time, so the occurrence table is the same on every run. */
|
|
101
|
+
const RECEIVED_AT = new Date('2026-09-23T12:00:00.000Z');
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build the snapshot from one side's definitions on the given inputs. The
|
|
105
|
+
* SAME function runs on both sides (the chassis carries a verbatim copy):
|
|
106
|
+
* the hub with its own inputs, the chassis with the inputs read from the
|
|
107
|
+
* hub's file. Deterministic: no clock, no randomness, no environment.
|
|
108
|
+
*/
|
|
109
|
+
export function buildWireSnapshot(defs: WireDefinitions, inputs: WireInputs): HubToolWireSnapshot {
|
|
110
|
+
const schemas = {} as Record<WireSchemaKey, unknown>;
|
|
111
|
+
const probes = {} as Record<WireSchemaKey, WireProbeResult[]>;
|
|
112
|
+
for (const key of WIRE_SCHEMA_KEYS) {
|
|
113
|
+
const schema = defs.schemas[key];
|
|
114
|
+
// A refinement has no JSON Schema form and is dropped on both sides
|
|
115
|
+
// alike; the probes below carry what it decides.
|
|
116
|
+
schemas[key] = z.toJSONSchema(schema, { unrepresentable: 'any' });
|
|
117
|
+
probes[key] = inputs.probes[key].map((probe) => ({
|
|
118
|
+
name: probe.name,
|
|
119
|
+
input: probe.input,
|
|
120
|
+
ok: schema.safeParse(probe.input).success
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
v: 1,
|
|
125
|
+
constants: {
|
|
126
|
+
USAGE_WRITE_SCOPE: defs.constants.USAGE_WRITE_SCOPE,
|
|
127
|
+
BILLING_PLANS: [...defs.constants.BILLING_PLANS],
|
|
128
|
+
USAGE_EVENTS_BATCH_MAX: defs.constants.USAGE_EVENTS_BATCH_MAX,
|
|
129
|
+
USAGE_EVENT_MAX_PAST_MS: defs.constants.USAGE_EVENT_MAX_PAST_MS,
|
|
130
|
+
USAGE_EVENT_MAX_FUTURE_MS: defs.constants.USAGE_EVENT_MAX_FUTURE_MS,
|
|
131
|
+
USAGE_CHECK_REASONS: [...defs.constants.USAGE_CHECK_REASONS],
|
|
132
|
+
USAGE_REJECT_REASONS: [...defs.constants.USAGE_REJECT_REASONS],
|
|
133
|
+
USAGE_ROUTE_ERRORS: { ...defs.constants.USAGE_ROUTE_ERRORS }
|
|
134
|
+
},
|
|
135
|
+
schemas,
|
|
136
|
+
occurrence: inputs.occurrenceOffsetsMs.map((offsetMs) => ({
|
|
137
|
+
offsetMs,
|
|
138
|
+
issue: defs.occurrenceIssue(new Date(RECEIVED_AT.getTime() + offsetMs), RECEIVED_AT)
|
|
139
|
+
})),
|
|
140
|
+
probes
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── The chassis's own half ─────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
/** The chassis's definitions: this package's copies of the hub's shapes, under the hub's keys. */
|
|
147
|
+
export const CHASSIS_WIRE_DEFINITIONS: WireDefinitions = {
|
|
148
|
+
constants: {
|
|
149
|
+
USAGE_WRITE_SCOPE,
|
|
150
|
+
BILLING_PLANS,
|
|
151
|
+
USAGE_EVENTS_BATCH_MAX,
|
|
152
|
+
USAGE_EVENT_MAX_PAST_MS,
|
|
153
|
+
USAGE_EVENT_MAX_FUTURE_MS,
|
|
154
|
+
USAGE_CHECK_REASONS,
|
|
155
|
+
USAGE_REJECT_REASONS,
|
|
156
|
+
USAGE_ROUTE_ERRORS
|
|
157
|
+
},
|
|
158
|
+
schemas: {
|
|
159
|
+
usageEvent: usageEventSchema,
|
|
160
|
+
usageEventsBatch: usageEventsBatchSchema,
|
|
161
|
+
usageIngestResult: usageIngestResultSchema,
|
|
162
|
+
usageEntitlements: entitlementProfileSchema,
|
|
163
|
+
usageCheckRequest: usageCheckRequestSchema,
|
|
164
|
+
usageCheck: usageCheckSchema
|
|
165
|
+
},
|
|
166
|
+
occurrenceIssue: usageEventOccurrenceIssue
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/** The inputs the hub computed its snapshot on, read back from its file: the offsets and the probe payloads. */
|
|
170
|
+
export function wireInputsOf(reference: HubToolWireSnapshot): WireInputs {
|
|
171
|
+
const probes = {} as Record<WireSchemaKey, readonly WireProbe[]>;
|
|
172
|
+
for (const key of WIRE_SCHEMA_KEYS) {
|
|
173
|
+
probes[key] = (reference.probes[key] ?? []).map((probe) => ({ name: probe.name, input: probe.input }));
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
occurrenceOffsetsMs: reference.occurrence.map((row) => row.offsetMs),
|
|
177
|
+
probes
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The snapshot the chassis's copies give on the hub's own inputs: what must equal the hub's file. */
|
|
182
|
+
export function chassisWireSnapshot(reference: HubToolWireSnapshot): HubToolWireSnapshot {
|
|
183
|
+
return buildWireSnapshot(CHASSIS_WIRE_DEFINITIONS, wireInputsOf(reference));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** One place where the hub's snapshot and the chassis's differ: its dotted path and the two values. */
|
|
187
|
+
export interface WireDifference {
|
|
188
|
+
path: string;
|
|
189
|
+
hub: unknown;
|
|
190
|
+
chassis: unknown;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
194
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function childPath(parent: string, key: string): string {
|
|
198
|
+
return /^[A-Za-z_$][\w$]*$/.test(key)
|
|
199
|
+
? parent === ''
|
|
200
|
+
? key
|
|
201
|
+
: `${parent}.${key}`
|
|
202
|
+
: `${parent}[${JSON.stringify(key)}]`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function compareAt(path: string, hub: unknown, chassis: unknown, out: WireDifference[]): void {
|
|
206
|
+
if (Array.isArray(hub) && Array.isArray(chassis)) {
|
|
207
|
+
if (hub.length !== chassis.length) {
|
|
208
|
+
out.push({ path, hub, chassis });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
for (let i = 0; i < hub.length; i += 1) compareAt(`${path}[${i}]`, hub[i], chassis[i], out);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (isPlainObject(hub) && isPlainObject(chassis)) {
|
|
215
|
+
const keys = [...new Set([...Object.keys(hub), ...Object.keys(chassis)])].sort();
|
|
216
|
+
for (const key of keys) compareAt(childPath(path, key), hub[key], chassis[key], out);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (!Object.is(hub, chassis)) out.push({ path, hub, chassis });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Every difference between the hub's snapshot and the chassis's, by dotted
|
|
224
|
+
* path (`constants.USAGE_CHECK_REASONS[2]`,
|
|
225
|
+
* `schemas.usageCheck.properties.reason.anyOf[0].enum`,
|
|
226
|
+
* `probes.usageCheck[3].ok`). Objects compare by the union of their keys,
|
|
227
|
+
* order ignored, an absent key and `undefined` being the same; arrays by
|
|
228
|
+
* index, a length difference being one difference at the array's path.
|
|
229
|
+
* Deterministic: keys sorted, arrays in order. Empty when they agree.
|
|
230
|
+
*/
|
|
231
|
+
export function compareWireSnapshots(
|
|
232
|
+
hub: HubToolWireSnapshot,
|
|
233
|
+
chassis: HubToolWireSnapshot
|
|
234
|
+
): WireDifference[] {
|
|
235
|
+
const out: WireDifference[] = [];
|
|
236
|
+
compareAt('', hub, chassis, out);
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** One line of figures for a snapshot: `6 shapes, N constants, M probes, K occurrence offsets`. */
|
|
241
|
+
export function describeWireSnapshot(snapshot: HubToolWireSnapshot): string {
|
|
242
|
+
const shapes = Object.keys(snapshot.schemas).length;
|
|
243
|
+
const constants = Object.keys(snapshot.constants).length;
|
|
244
|
+
const probes = Object.values(snapshot.probes).reduce((n, list) => n + list.length, 0);
|
|
245
|
+
return `${shapes} shapes, ${constants} constants, ${probes} probes, ${snapshot.occurrence.length} occurrence offsets`;
|
|
246
|
+
}
|