@antasphere/chassis-contract 0.0.0-stage → 1.0.1
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 +265 -0
- package/dist/entitlements.d.ts.map +1 -0
- package/dist/entitlements.js +155 -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 +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -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/package.json +41 -3
- package/src/define.ts +64 -0
- package/src/entitlements.ts +340 -0
- package/src/identity.ts +49 -0
- package/src/index.ts +37 -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/test/define.test.ts +59 -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
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
2
|
+
import { defineChassisContract, type ApiKeyInfoOf, type MeResponseOf, type ScopeOf } from '../src/index.js';
|
|
3
|
+
import { defineChassisRoutes } from '../src/routes/index.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The factory is the one place the chassis meets a tool's scope vocabulary.
|
|
7
|
+
* Proven here with scopes that are NOT any real tool's, so the chassis stays
|
|
8
|
+
* honest about never spelling one.
|
|
9
|
+
*/
|
|
10
|
+
const a = defineChassisContract({ scopes: ['widgets:read', 'widgets:write'] });
|
|
11
|
+
const b = defineChassisContract({ scopes: ['gadgets:admin'] });
|
|
12
|
+
|
|
13
|
+
describe('defineChassisContract', () => {
|
|
14
|
+
it('builds the scope enum from the scopes it is given, in order', () => {
|
|
15
|
+
expect(a.scopeSchema.options).toEqual(['widgets:read', 'widgets:write']);
|
|
16
|
+
expect(b.scopeSchema.options).toEqual(['gadgets:admin']);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('types Scope as the union of the literals passed in', () => {
|
|
20
|
+
expectTypeOf<ScopeOf<typeof a>>().toEqualTypeOf<'widgets:read' | 'widgets:write'>();
|
|
21
|
+
expectTypeOf<ApiKeyInfoOf<typeof a>['scopes']>().toEqualTypeOf<Array<'widgets:read' | 'widgets:write'>>();
|
|
22
|
+
expectTypeOf<MeResponseOf<typeof b>['scopes']>().toEqualTypeOf<Array<'gadgets:admin'> | null>();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('threads the enum into every schema that carries scopes', () => {
|
|
26
|
+
expect(a.apiKeyCreateSchema.safeParse({ name: 'k', scopes: ['widgets:read'] }).success).toBe(true);
|
|
27
|
+
expect(a.apiKeyCreateSchema.safeParse({ name: 'k', scopes: ['gadgets:admin'] }).success).toBe(false);
|
|
28
|
+
expect(b.apiKeyCreateSchema.safeParse({ name: 'k', scopes: ['gadgets:admin'] }).success).toBe(true);
|
|
29
|
+
expect(a.cliAuthCompletedSchema.shape.apiKey).toBe(a.apiKeySchema);
|
|
30
|
+
expect(a.meResponseSchema.shape.scopes.unwrap().element).toBe(a.scopeSchema);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('holds no module-level state: two instantiations never share a schema', () => {
|
|
34
|
+
expect(a.scopeSchema).not.toBe(b.scopeSchema);
|
|
35
|
+
expect(a.apiKeySchema).not.toBe(b.apiKeySchema);
|
|
36
|
+
expect(a.meResponseSchema).not.toBe(b.meResponseSchema);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('defineChassisRoutes', () => {
|
|
41
|
+
it('returns the eight route contracts over the instantiated schemas', () => {
|
|
42
|
+
const routes = defineChassisRoutes(
|
|
43
|
+
a,
|
|
44
|
+
{ cliKeyScopesLabel: 'widgets:read+write', scopes: { dataExport: 'widgets:export' } },
|
|
45
|
+
{ fileInUseOpenApi: 'file_in_use: referenced by a widget' }
|
|
46
|
+
);
|
|
47
|
+
expect(Object.entries(routes).map(([name, r]) => `${name} ${r.method} ${r.path}`)).toEqual([
|
|
48
|
+
'meRoute get /me',
|
|
49
|
+
'apiKeysListRoute get /api-keys',
|
|
50
|
+
'apiKeyCreateRoute post /api-keys',
|
|
51
|
+
'apiKeyRevokeRoute delete /api-keys/{id}',
|
|
52
|
+
'cliAuthCompleteRoute post /cli/auth/complete',
|
|
53
|
+
'ssoCliConnectRoute post /sso/cli-connect',
|
|
54
|
+
'workspaceExportRoute get /workspace/export',
|
|
55
|
+
'fileDeleteRoute delete /files/{id}'
|
|
56
|
+
]);
|
|
57
|
+
expect(routes.meRoute.responses[200].content['application/json'].schema).toBe(a.meResponseSchema);
|
|
58
|
+
});
|
|
59
|
+
});
|
package/README.md
DELETED