@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.
Files changed (124) hide show
  1. package/LICENSE +82 -0
  2. package/dist/define.d.ts +177 -0
  3. package/dist/define.d.ts.map +1 -0
  4. package/dist/define.js +35 -0
  5. package/dist/define.js.map +1 -0
  6. package/dist/entitlements.d.ts +265 -0
  7. package/dist/entitlements.d.ts.map +1 -0
  8. package/dist/entitlements.js +155 -0
  9. package/dist/entitlements.js.map +1 -0
  10. package/dist/identity.d.ts +56 -0
  11. package/dist/identity.d.ts.map +1 -0
  12. package/dist/identity.js +2 -0
  13. package/dist/identity.js.map +1 -0
  14. package/dist/index.d.ts +22 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +28 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/routes/index.d.ts +5403 -0
  19. package/dist/routes/index.d.ts.map +1 -0
  20. package/dist/routes/index.js +940 -0
  21. package/dist/routes/index.js.map +1 -0
  22. package/dist/schemas/api-keys.d.ts +54 -0
  23. package/dist/schemas/api-keys.d.ts.map +1 -0
  24. package/dist/schemas/api-keys.js +48 -0
  25. package/dist/schemas/api-keys.js.map +1 -0
  26. package/dist/schemas/audit.d.ts +74 -0
  27. package/dist/schemas/audit.d.ts.map +1 -0
  28. package/dist/schemas/audit.js +65 -0
  29. package/dist/schemas/audit.js.map +1 -0
  30. package/dist/schemas/break-glass.d.ts +42 -0
  31. package/dist/schemas/break-glass.d.ts.map +1 -0
  32. package/dist/schemas/break-glass.js +38 -0
  33. package/dist/schemas/break-glass.js.map +1 -0
  34. package/dist/schemas/cli-auth.d.ts +59 -0
  35. package/dist/schemas/cli-auth.d.ts.map +1 -0
  36. package/dist/schemas/cli-auth.js +68 -0
  37. package/dist/schemas/cli-auth.js.map +1 -0
  38. package/dist/schemas/common.d.ts +96 -0
  39. package/dist/schemas/common.d.ts.map +1 -0
  40. package/dist/schemas/common.js +205 -0
  41. package/dist/schemas/common.js.map +1 -0
  42. package/dist/schemas/demo-passes.d.ts +90 -0
  43. package/dist/schemas/demo-passes.d.ts.map +1 -0
  44. package/dist/schemas/demo-passes.js +60 -0
  45. package/dist/schemas/demo-passes.js.map +1 -0
  46. package/dist/schemas/files.d.ts +39 -0
  47. package/dist/schemas/files.d.ts.map +1 -0
  48. package/dist/schemas/files.js +26 -0
  49. package/dist/schemas/files.js.map +1 -0
  50. package/dist/schemas/instance.d.ts +58 -0
  51. package/dist/schemas/instance.d.ts.map +1 -0
  52. package/dist/schemas/instance.js +70 -0
  53. package/dist/schemas/instance.js.map +1 -0
  54. package/dist/schemas/invitations.d.ts +100 -0
  55. package/dist/schemas/invitations.d.ts.map +1 -0
  56. package/dist/schemas/invitations.js +55 -0
  57. package/dist/schemas/invitations.js.map +1 -0
  58. package/dist/schemas/me.d.ts +81 -0
  59. package/dist/schemas/me.d.ts.map +1 -0
  60. package/dist/schemas/me.js +178 -0
  61. package/dist/schemas/me.js.map +1 -0
  62. package/dist/schemas/members.d.ts +60 -0
  63. package/dist/schemas/members.d.ts.map +1 -0
  64. package/dist/schemas/members.js +43 -0
  65. package/dist/schemas/members.js.map +1 -0
  66. package/dist/schemas/projects.d.ts +232 -0
  67. package/dist/schemas/projects.d.ts.map +1 -0
  68. package/dist/schemas/projects.js +140 -0
  69. package/dist/schemas/projects.js.map +1 -0
  70. package/dist/schemas/scope.d.ts +14 -0
  71. package/dist/schemas/scope.d.ts.map +1 -0
  72. package/dist/schemas/scope.js +5 -0
  73. package/dist/schemas/scope.js.map +1 -0
  74. package/dist/schemas/setup.d.ts +28 -0
  75. package/dist/schemas/setup.d.ts.map +1 -0
  76. package/dist/schemas/setup.js +28 -0
  77. package/dist/schemas/setup.js.map +1 -0
  78. package/dist/schemas/sso-connect.d.ts +30 -0
  79. package/dist/schemas/sso-connect.d.ts.map +1 -0
  80. package/dist/schemas/sso-connect.js +38 -0
  81. package/dist/schemas/sso-connect.js.map +1 -0
  82. package/dist/schemas/sso-logout.d.ts +17 -0
  83. package/dist/schemas/sso-logout.d.ts.map +1 -0
  84. package/dist/schemas/sso-logout.js +16 -0
  85. package/dist/schemas/sso-logout.js.map +1 -0
  86. package/dist/schemas/teams.d.ts +100 -0
  87. package/dist/schemas/teams.d.ts.map +1 -0
  88. package/dist/schemas/teams.js +84 -0
  89. package/dist/schemas/teams.js.map +1 -0
  90. package/dist/schemas/workspaces.d.ts +99 -0
  91. package/dist/schemas/workspaces.d.ts.map +1 -0
  92. package/dist/schemas/workspaces.js +96 -0
  93. package/dist/schemas/workspaces.js.map +1 -0
  94. package/dist/seams.d.ts +185 -0
  95. package/dist/seams.d.ts.map +1 -0
  96. package/dist/seams.js +37 -0
  97. package/dist/seams.js.map +1 -0
  98. package/package.json +41 -3
  99. package/src/define.ts +64 -0
  100. package/src/entitlements.ts +340 -0
  101. package/src/identity.ts +49 -0
  102. package/src/index.ts +37 -0
  103. package/src/routes/index.ts +1146 -0
  104. package/src/schemas/api-keys.ts +53 -0
  105. package/src/schemas/audit.ts +74 -0
  106. package/src/schemas/break-glass.ts +46 -0
  107. package/src/schemas/cli-auth.ts +78 -0
  108. package/src/schemas/common.ts +206 -0
  109. package/src/schemas/demo-passes.ts +73 -0
  110. package/src/schemas/files.ts +30 -0
  111. package/src/schemas/instance.ts +71 -0
  112. package/src/schemas/invitations.ts +66 -0
  113. package/src/schemas/me.ts +184 -0
  114. package/src/schemas/members.ts +53 -0
  115. package/src/schemas/projects.ts +174 -0
  116. package/src/schemas/scope.ts +18 -0
  117. package/src/schemas/setup.ts +31 -0
  118. package/src/schemas/sso-connect.ts +40 -0
  119. package/src/schemas/sso-logout.ts +17 -0
  120. package/src/schemas/teams.ts +101 -0
  121. package/src/schemas/workspaces.ts +109 -0
  122. package/src/seams.ts +202 -0
  123. package/test/define.test.ts +59 -0
  124. 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
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.