@rayspec/auth-core 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +105 -0
  2. package/dist/api-key.d.ts +28 -0
  3. package/dist/api-key.d.ts.map +1 -0
  4. package/dist/api-key.js +56 -0
  5. package/dist/api-key.js.map +1 -0
  6. package/dist/audit.d.ts +26 -0
  7. package/dist/audit.d.ts.map +1 -0
  8. package/dist/audit.js +11 -0
  9. package/dist/audit.js.map +1 -0
  10. package/dist/authz.d.ts +66 -0
  11. package/dist/authz.d.ts.map +1 -0
  12. package/dist/authz.js +112 -0
  13. package/dist/authz.js.map +1 -0
  14. package/dist/config.d.ts +23 -0
  15. package/dist/config.d.ts.map +1 -0
  16. package/dist/config.js +42 -0
  17. package/dist/config.js.map +1 -0
  18. package/dist/deferrals.d.ts +33 -0
  19. package/dist/deferrals.d.ts.map +1 -0
  20. package/dist/deferrals.js +38 -0
  21. package/dist/deferrals.js.map +1 -0
  22. package/dist/dto.d.ts +337 -0
  23. package/dist/dto.d.ts.map +1 -0
  24. package/dist/dto.js +221 -0
  25. package/dist/dto.js.map +1 -0
  26. package/dist/email.d.ts +24 -0
  27. package/dist/email.d.ts.map +1 -0
  28. package/dist/email.js +46 -0
  29. package/dist/email.js.map +1 -0
  30. package/dist/errors.d.ts +87 -0
  31. package/dist/errors.d.ts.map +1 -0
  32. package/dist/errors.js +104 -0
  33. package/dist/errors.js.map +1 -0
  34. package/dist/index.d.ts +14 -0
  35. package/dist/index.d.ts.map +1 -0
  36. package/dist/index.js +14 -0
  37. package/dist/index.js.map +1 -0
  38. package/dist/invite.d.ts +18 -0
  39. package/dist/invite.d.ts.map +1 -0
  40. package/dist/invite.js +33 -0
  41. package/dist/invite.js.map +1 -0
  42. package/dist/password.d.ts +17 -0
  43. package/dist/password.d.ts.map +1 -0
  44. package/dist/password.js +46 -0
  45. package/dist/password.js.map +1 -0
  46. package/dist/rate-limit.d.ts +93 -0
  47. package/dist/rate-limit.d.ts.map +1 -0
  48. package/dist/rate-limit.js +156 -0
  49. package/dist/rate-limit.js.map +1 -0
  50. package/dist/session.d.ts +18 -0
  51. package/dist/session.d.ts.map +1 -0
  52. package/dist/session.js +41 -0
  53. package/dist/session.js.map +1 -0
  54. package/dist/tokens.d.ts +117 -0
  55. package/dist/tokens.d.ts.map +1 -0
  56. package/dist/tokens.js +150 -0
  57. package/dist/tokens.js.map +1 -0
  58. package/package.json +33 -0
package/dist/dto.d.ts ADDED
@@ -0,0 +1,337 @@
1
+ /**
2
+ * RaySpec HTTP DTOs (Zod) — the ONLY shapes that cross the public HTTP boundary. No
3
+ * node-oidc-provider / jose / argon2 / Drizzle type appears here; handlers translate to/from
4
+ * these. Mass-assignment defense: org_id / role / tenant_id are NEVER body-bindable — they are
5
+ * server-derived. password_hash / key_hash / token_hash NEVER appear in any response DTO.
6
+ *
7
+ * The ONE deliberate, gated secret-in-a-response exception is `TokenResponse.refreshToken`
8
+ * — the rotated refresh secret, returned to a NON-browser client ONLY on the
9
+ * operator-gated + per-request opt-in path so it can store it in OS-secure storage (precedent:
10
+ * `MintApiKeyResponse.plaintext`). It is never returned to a browser flow and never logged/audited.
11
+ */
12
+ import { z } from 'zod';
13
+ import { API_KEY_GRANTABLE } from './authz.js';
14
+ /** Membership roles (owner|admin|member). */
15
+ export declare const Role: z.ZodEnum<{
16
+ owner: "owner";
17
+ admin: "admin";
18
+ member: "member";
19
+ }>;
20
+ export type Role = z.infer<typeof Role>;
21
+ export declare const RegisterRequest: z.ZodObject<{
22
+ email: z.ZodString;
23
+ password: z.ZodString;
24
+ orgName: z.ZodOptional<z.ZodString>;
25
+ deliverRefreshTokenInBody: z.ZodOptional<z.ZodBoolean>;
26
+ }, z.core.$strip>;
27
+ export type RegisterRequest = z.infer<typeof RegisterRequest>;
28
+ export declare const LoginRequest: z.ZodObject<{
29
+ email: z.ZodString;
30
+ password: z.ZodString;
31
+ deliverRefreshTokenInBody: z.ZodOptional<z.ZodBoolean>;
32
+ }, z.core.$strip>;
33
+ export type LoginRequest = z.infer<typeof LoginRequest>;
34
+ /**
35
+ * The access-token + (optional) active-org envelope returned by login / refresh / switch.
36
+ *
37
+ * `refreshToken` is a DELIBERATE, GATED exception to the "no secret in a response DTO" rule
38
+ * — populated ONLY when the deployment enables `RAYSPEC_BODY_REFRESH_ENABLED`
39
+ * AND the request opted in (`deliverRefreshTokenInBody`), i.e. a non-browser client storing the
40
+ * secret in OS-secure storage. Precedent: `MintApiKeyResponse.plaintext` (a secret returned once
41
+ * to the client by design). The secret is short-lived, rotates every refresh, and is
42
+ * family-revocable; a browser flow never receives it (it never opts in), and it is never logged.
43
+ */
44
+ export declare const TokenResponse: z.ZodObject<{
45
+ accessToken: z.ZodString;
46
+ tokenType: z.ZodLiteral<"Bearer">;
47
+ expiresIn: z.ZodNumber;
48
+ activeOrgId: z.ZodNullable<z.ZodString>;
49
+ refreshToken: z.ZodOptional<z.ZodString>;
50
+ }, z.core.$strip>;
51
+ export type TokenResponse = z.infer<typeof TokenResponse>;
52
+ /** Refresh may carry the secret in the body (desktop/CLI) instead of the cookie. */
53
+ export declare const RefreshRequest: z.ZodObject<{
54
+ refreshToken: z.ZodOptional<z.ZodString>;
55
+ deliverRefreshTokenInBody: z.ZodOptional<z.ZodBoolean>;
56
+ }, z.core.$strip>;
57
+ export type RefreshRequest = z.infer<typeof RefreshRequest>;
58
+ export declare const MembershipView: z.ZodObject<{
59
+ orgId: z.ZodString;
60
+ role: z.ZodEnum<{
61
+ owner: "owner";
62
+ admin: "admin";
63
+ member: "member";
64
+ }>;
65
+ }, z.core.$strip>;
66
+ export type MembershipView = z.infer<typeof MembershipView>;
67
+ export declare const MeResponse: z.ZodObject<{
68
+ userId: z.ZodString;
69
+ email: z.ZodString;
70
+ emailVerified: z.ZodBoolean;
71
+ memberships: z.ZodArray<z.ZodObject<{
72
+ orgId: z.ZodString;
73
+ role: z.ZodEnum<{
74
+ owner: "owner";
75
+ admin: "admin";
76
+ member: "member";
77
+ }>;
78
+ }, z.core.$strip>>;
79
+ activeOrgId: z.ZodNullable<z.ZodString>;
80
+ }, z.core.$strip>;
81
+ export type MeResponse = z.infer<typeof MeResponse>;
82
+ export declare const CreateOrgRequest: z.ZodObject<{
83
+ name: z.ZodString;
84
+ slug: z.ZodOptional<z.ZodString>;
85
+ }, z.core.$strip>;
86
+ export type CreateOrgRequest = z.infer<typeof CreateOrgRequest>;
87
+ export declare const OrgView: z.ZodObject<{
88
+ id: z.ZodString;
89
+ name: z.ZodString;
90
+ slug: z.ZodString;
91
+ role: z.ZodEnum<{
92
+ owner: "owner";
93
+ admin: "admin";
94
+ member: "member";
95
+ }>;
96
+ }, z.core.$strip>;
97
+ export type OrgView = z.infer<typeof OrgView>;
98
+ export declare const OrgListResponse: z.ZodObject<{
99
+ orgs: z.ZodArray<z.ZodObject<{
100
+ id: z.ZodString;
101
+ name: z.ZodString;
102
+ slug: z.ZodString;
103
+ role: z.ZodEnum<{
104
+ owner: "owner";
105
+ admin: "admin";
106
+ member: "member";
107
+ }>;
108
+ }, z.core.$strip>>;
109
+ }, z.core.$strip>;
110
+ export type OrgListResponse = z.infer<typeof OrgListResponse>;
111
+ /** Change a member's role within an org (owner|admin only; last-owner demotion is blocked). */
112
+ export declare const ChangeMemberRoleRequest: z.ZodObject<{
113
+ role: z.ZodEnum<{
114
+ owner: "owner";
115
+ admin: "admin";
116
+ member: "member";
117
+ }>;
118
+ }, z.core.$strip>;
119
+ export type ChangeMemberRoleRequest = z.infer<typeof ChangeMemberRoleRequest>;
120
+ /**
121
+ * Add a user to an org by email (owner-only). `org_id`/`role`/`user_id` are NEVER body-bindable —
122
+ * the org is the server-derived tenant and the added user always joins as a plain `member`. Only
123
+ * the email is client-supplied; it is normalized server-side before any lookup/write.
124
+ */
125
+ export declare const AddOrgMemberRequest: z.ZodObject<{
126
+ email: z.ZodString;
127
+ }, z.core.$strip>;
128
+ export type AddOrgMemberRequest = z.infer<typeof AddOrgMemberRequest>;
129
+ /** One member of an org (the id + email + role) — never a hash/secret. */
130
+ export declare const OrgMemberView: z.ZodObject<{
131
+ userId: z.ZodString;
132
+ email: z.ZodString;
133
+ role: z.ZodEnum<{
134
+ owner: "owner";
135
+ admin: "admin";
136
+ member: "member";
137
+ }>;
138
+ }, z.core.$strip>;
139
+ export type OrgMemberView = z.infer<typeof OrgMemberView>;
140
+ /**
141
+ * The add-member result. `oneTimePassword` is a DELIBERATE, single-shot secret-in-a-response — it
142
+ * is populated ONLY when the added email had no existing user, so a fresh account is provisioned
143
+ * with a random initial password the operator conveys out-of-band (core has no outbound mail). It
144
+ * is shown EXACTLY ONCE, never stored in plaintext, and never returned again (precedent:
145
+ * `MintApiKeyResponse.plaintext`). Absent when an existing user was added.
146
+ */
147
+ export declare const AddOrgMemberResponse: z.ZodObject<{
148
+ userId: z.ZodString;
149
+ email: z.ZodString;
150
+ role: z.ZodEnum<{
151
+ owner: "owner";
152
+ admin: "admin";
153
+ member: "member";
154
+ }>;
155
+ oneTimePassword: z.ZodOptional<z.ZodString>;
156
+ }, z.core.$strip>;
157
+ export type AddOrgMemberResponse = z.infer<typeof AddOrgMemberResponse>;
158
+ export declare const OrgMemberListResponse: z.ZodObject<{
159
+ members: z.ZodArray<z.ZodObject<{
160
+ userId: z.ZodString;
161
+ email: z.ZodString;
162
+ role: z.ZodEnum<{
163
+ owner: "owner";
164
+ admin: "admin";
165
+ member: "member";
166
+ }>;
167
+ }, z.core.$strip>>;
168
+ }, z.core.$strip>;
169
+ export type OrgMemberListResponse = z.infer<typeof OrgMemberListResponse>;
170
+ /**
171
+ * Issue an out-of-band org invite (owner-only). `org_id`/`tenant_id` are NEVER body-bindable — the
172
+ * org is the server-derived tenant. `email` is normalized server-side. `role` defaults to `member`.
173
+ * `expiresInSeconds` is an OPTIONAL requested lifetime clamped server-side to a floor/ceiling.
174
+ *
175
+ * The issue path deliberately does NOT look up whether the email has an account, so the response +
176
+ * timing are IDENTICAL whether or not it does — no account-existence oracle (the account-existence
177
+ * check happens only at redeem, performed by the invitee).
178
+ */
179
+ export declare const IssueInviteRequest: z.ZodObject<{
180
+ email: z.ZodString;
181
+ role: z.ZodDefault<z.ZodEnum<{
182
+ owner: "owner";
183
+ admin: "admin";
184
+ member: "member";
185
+ }>>;
186
+ expiresInSeconds: z.ZodOptional<z.ZodNumber>;
187
+ }, z.core.$strip>;
188
+ export type IssueInviteRequest = z.infer<typeof IssueInviteRequest>;
189
+ /**
190
+ * The issued invite. `inviteToken` is a DELIBERATE, single-shot secret-in-a-response — the opaque
191
+ * token is shown EXACTLY ONCE (the owner conveys it out-of-band; only its hash is stored), never
192
+ * returned again (precedent: `MintApiKeyResponse.plaintext`). The response carries NO account-existence
193
+ * signal (it is identical for any email).
194
+ */
195
+ export declare const IssueInviteResponse: z.ZodObject<{
196
+ inviteToken: z.ZodString;
197
+ email: z.ZodString;
198
+ role: z.ZodEnum<{
199
+ owner: "owner";
200
+ admin: "admin";
201
+ member: "member";
202
+ }>;
203
+ expiresAt: z.ZodString;
204
+ }, z.core.$strip>;
205
+ export type IssueInviteResponse = z.infer<typeof IssueInviteResponse>;
206
+ /**
207
+ * Redeem an invite (the INVITEE acts, not the owner). The org is resolved FROM the token, never a URL.
208
+ * `password` is REQUIRED only when the invited email has no account yet (the invitee sets their own
209
+ * initial credential at accept); it is IGNORED when the email already has an account (the invitee must
210
+ * instead be authenticated as that account — a token bearer can never set/reset an existing user's
211
+ * password). `password` shares the register/login shape policy.
212
+ */
213
+ export declare const AcceptInviteRequest: z.ZodObject<{
214
+ token: z.ZodString;
215
+ password: z.ZodOptional<z.ZodString>;
216
+ deliverRefreshTokenInBody: z.ZodOptional<z.ZodBoolean>;
217
+ }, z.core.$strip>;
218
+ export type AcceptInviteRequest = z.infer<typeof AcceptInviteRequest>;
219
+ /**
220
+ * The accept result — a token envelope scoped to the joined org (so the response is immediately
221
+ * usable), plus the membership. `refreshToken` is the gated+opt-in body-delivered refresh secret,
222
+ * present ONLY on the new-account provisioning path (the invitee is freshly logged in) when the
223
+ * deployment enables it; an authenticated existing-account accept receives only a fresh org-scoped
224
+ * access token (its refresh session is untouched).
225
+ */
226
+ export declare const AcceptInviteResponse: z.ZodObject<{
227
+ accessToken: z.ZodString;
228
+ tokenType: z.ZodLiteral<"Bearer">;
229
+ expiresIn: z.ZodNumber;
230
+ activeOrgId: z.ZodString;
231
+ userId: z.ZodString;
232
+ role: z.ZodEnum<{
233
+ owner: "owner";
234
+ admin: "admin";
235
+ member: "member";
236
+ }>;
237
+ refreshToken: z.ZodOptional<z.ZodString>;
238
+ }, z.core.$strip>;
239
+ export type AcceptInviteResponse = z.infer<typeof AcceptInviteResponse>;
240
+ /**
241
+ * The known api-key scopes (closed set; intersected with requested on mint). `store:read`/
242
+ * `store:write` let a programmatic/agency consumer (a desktop app / automation) READ
243
+ * and WRITE declared-store data via an org-scoped api-key — the deployer grants the write scope
244
+ * EXPLICITLY at mint (it is not implicit).
245
+ *
246
+ * DERIVED from `API_KEY_GRANTABLE` (authz.ts) — the SINGLE source of truth shared with `authorize()`.
247
+ * This is not a parallel literal that can silently drift from the runtime gate: a scope minted here
248
+ * that authorize() would never grant (or vice-versa) is structurally impossible, because both read
249
+ * the same array. (The authz.test.ts derived-invariant test additionally pins the equality.)
250
+ */
251
+ export declare const ApiKeyScope: z.ZodEnum<{ [K in (typeof API_KEY_GRANTABLE)[number]]: K; }>;
252
+ export type ApiKeyScope = z.infer<typeof ApiKeyScope>;
253
+ export declare const MintApiKeyRequest: z.ZodObject<{
254
+ name: z.ZodOptional<z.ZodString>;
255
+ scopes: z.ZodDefault<z.ZodArray<z.ZodEnum<{
256
+ "agent:run": "agent:run";
257
+ "agent:read": "agent:read";
258
+ "store:read": "store:read";
259
+ "store:write": "store:write";
260
+ "org:read": "org:read";
261
+ "org:member:add": "org:member:add";
262
+ "org:member:change": "org:member:change";
263
+ "org:switch": "org:switch";
264
+ "apikey:read": "apikey:read";
265
+ "apikey:mint": "apikey:mint";
266
+ "apikey:revoke": "apikey:revoke";
267
+ }>>>;
268
+ }, z.core.$strip>;
269
+ export type MintApiKeyRequest = z.infer<typeof MintApiKeyRequest>;
270
+ /** The mint response is the ONLY place plaintext is returned — ONCE. */
271
+ export declare const MintApiKeyResponse: z.ZodObject<{
272
+ id: z.ZodString;
273
+ keyPrefix: z.ZodString;
274
+ plaintext: z.ZodString;
275
+ scopes: z.ZodArray<z.ZodEnum<{
276
+ "agent:run": "agent:run";
277
+ "agent:read": "agent:read";
278
+ "store:read": "store:read";
279
+ "store:write": "store:write";
280
+ "org:read": "org:read";
281
+ "org:member:add": "org:member:add";
282
+ "org:member:change": "org:member:change";
283
+ "org:switch": "org:switch";
284
+ "apikey:read": "apikey:read";
285
+ "apikey:mint": "apikey:mint";
286
+ "apikey:revoke": "apikey:revoke";
287
+ }>>;
288
+ }, z.core.$strip>;
289
+ export type MintApiKeyResponse = z.infer<typeof MintApiKeyResponse>;
290
+ /**
291
+ * The REDACTED mint snapshot — the ONLY shape persisted in idempotency_keys.snapshot and the
292
+ * shape returned on an Idempotency-Key REPLAY. It NEVER carries
293
+ * `plaintext`: the secret is shown EXACTLY ONCE on the original mint; a retry returns only the
294
+ * non-secret metadata so a DB dump of the no-TTL snapshot column yields no usable `<prefix>.<secret>`
295
+ * credential. `replayed: true` signals the caller that the plaintext is not available again.
296
+ */
297
+ export declare const MintApiKeyReplay: z.ZodObject<{
298
+ id: z.ZodString;
299
+ keyPrefix: z.ZodString;
300
+ scopes: z.ZodArray<z.ZodEnum<{
301
+ "agent:run": "agent:run";
302
+ "agent:read": "agent:read";
303
+ "store:read": "store:read";
304
+ "store:write": "store:write";
305
+ "org:read": "org:read";
306
+ "org:member:add": "org:member:add";
307
+ "org:member:change": "org:member:change";
308
+ "org:switch": "org:switch";
309
+ "apikey:read": "apikey:read";
310
+ "apikey:mint": "apikey:mint";
311
+ "apikey:revoke": "apikey:revoke";
312
+ }>>;
313
+ replayed: z.ZodLiteral<true>;
314
+ }, z.core.$strip>;
315
+ export type MintApiKeyReplay = z.infer<typeof MintApiKeyReplay>;
316
+ /** Listing never includes plaintext or the hash. */
317
+ export declare const ApiKeyView: z.ZodObject<{
318
+ id: z.ZodString;
319
+ keyPrefix: z.ZodString;
320
+ scopes: z.ZodArray<z.ZodString>;
321
+ lastUsedAt: z.ZodNullable<z.ZodString>;
322
+ revokedAt: z.ZodNullable<z.ZodString>;
323
+ createdAt: z.ZodString;
324
+ }, z.core.$strip>;
325
+ export type ApiKeyView = z.infer<typeof ApiKeyView>;
326
+ export declare const ApiKeyListResponse: z.ZodObject<{
327
+ keys: z.ZodArray<z.ZodObject<{
328
+ id: z.ZodString;
329
+ keyPrefix: z.ZodString;
330
+ scopes: z.ZodArray<z.ZodString>;
331
+ lastUsedAt: z.ZodNullable<z.ZodString>;
332
+ revokedAt: z.ZodNullable<z.ZodString>;
333
+ createdAt: z.ZodString;
334
+ }, z.core.$strip>>;
335
+ }, z.core.$strip>;
336
+ export type ApiKeyListResponse = z.infer<typeof ApiKeyListResponse>;
337
+ //# sourceMappingURL=dto.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dto.d.ts","sourceRoot":"","sources":["../src/dto.ts"],"names":[],"mappings":"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"}
package/dist/dto.js ADDED
@@ -0,0 +1,221 @@
1
+ /**
2
+ * RaySpec HTTP DTOs (Zod) — the ONLY shapes that cross the public HTTP boundary. No
3
+ * node-oidc-provider / jose / argon2 / Drizzle type appears here; handlers translate to/from
4
+ * these. Mass-assignment defense: org_id / role / tenant_id are NEVER body-bindable — they are
5
+ * server-derived. password_hash / key_hash / token_hash NEVER appear in any response DTO.
6
+ *
7
+ * The ONE deliberate, gated secret-in-a-response exception is `TokenResponse.refreshToken`
8
+ * — the rotated refresh secret, returned to a NON-browser client ONLY on the
9
+ * operator-gated + per-request opt-in path so it can store it in OS-secure storage (precedent:
10
+ * `MintApiKeyResponse.plaintext`). It is never returned to a browser flow and never logged/audited.
11
+ */
12
+ import { z } from 'zod';
13
+ import { API_KEY_GRANTABLE } from './authz.js';
14
+ /** Membership roles (owner|admin|member). */
15
+ export const Role = z.enum(['owner', 'admin', 'member']);
16
+ /** Email + password are validated for SHAPE here; normalization happens in the service. */
17
+ const passwordField = z.string().min(8).max(1024);
18
+ const emailField = z.string().min(3).max(254);
19
+ // ---- auth ----------------------------------------------------------------------------------
20
+ /**
21
+ * A NON-browser client (desktop/CLI) opts in to receiving the rotated refresh
22
+ * secret in the JSON response body (so it can store it in OS-secure storage and refresh without the
23
+ * httpOnly cookie). Absent/false ⇒ today's behavior. Only honored when the deployment ALSO enables
24
+ * the operator gate (`bodyRefreshEnabled`); a browser flow never sets it. Shared by register/login/
25
+ * refresh.
26
+ */
27
+ const deliverRefreshTokenInBody = z.boolean().optional();
28
+ export const RegisterRequest = z.object({
29
+ email: emailField,
30
+ password: passwordField,
31
+ /** Optional: auto-create an org + owner membership for the new user. */
32
+ orgName: z.string().min(1).max(200).optional(),
33
+ deliverRefreshTokenInBody,
34
+ });
35
+ export const LoginRequest = z.object({
36
+ email: emailField,
37
+ password: passwordField,
38
+ deliverRefreshTokenInBody,
39
+ });
40
+ /**
41
+ * The access-token + (optional) active-org envelope returned by login / refresh / switch.
42
+ *
43
+ * `refreshToken` is a DELIBERATE, GATED exception to the "no secret in a response DTO" rule
44
+ * — populated ONLY when the deployment enables `RAYSPEC_BODY_REFRESH_ENABLED`
45
+ * AND the request opted in (`deliverRefreshTokenInBody`), i.e. a non-browser client storing the
46
+ * secret in OS-secure storage. Precedent: `MintApiKeyResponse.plaintext` (a secret returned once
47
+ * to the client by design). The secret is short-lived, rotates every refresh, and is
48
+ * family-revocable; a browser flow never receives it (it never opts in), and it is never logged.
49
+ */
50
+ export const TokenResponse = z.object({
51
+ accessToken: z.string(),
52
+ tokenType: z.literal('Bearer'),
53
+ expiresIn: z.number().int().positive(),
54
+ activeOrgId: z.string().uuid().nullable(),
55
+ /** The rotated refresh secret — gated+opt-in only (see the type doc above). Absent otherwise. */
56
+ refreshToken: z.string().optional(),
57
+ });
58
+ /** Refresh may carry the secret in the body (desktop/CLI) instead of the cookie. */
59
+ export const RefreshRequest = z.object({
60
+ refreshToken: z.string().min(1).optional(),
61
+ deliverRefreshTokenInBody,
62
+ });
63
+ export const MembershipView = z.object({
64
+ orgId: z.string().uuid(),
65
+ role: Role,
66
+ });
67
+ export const MeResponse = z.object({
68
+ userId: z.string().uuid(),
69
+ email: z.string(),
70
+ emailVerified: z.boolean(),
71
+ memberships: z.array(MembershipView),
72
+ activeOrgId: z.string().uuid().nullable(),
73
+ });
74
+ // ---- orgs ----------------------------------------------------------------------------------
75
+ export const CreateOrgRequest = z.object({
76
+ name: z.string().min(1).max(200),
77
+ /** Optional slug; defaults to a normalized form of name. Lowercased + uniqueness server-side. */
78
+ slug: z.string().min(1).max(200).optional(),
79
+ });
80
+ export const OrgView = z.object({
81
+ id: z.string().uuid(),
82
+ name: z.string(),
83
+ slug: z.string(),
84
+ role: Role,
85
+ });
86
+ export const OrgListResponse = z.object({ orgs: z.array(OrgView) });
87
+ /** Change a member's role within an org (owner|admin only; last-owner demotion is blocked). */
88
+ export const ChangeMemberRoleRequest = z.object({ role: Role });
89
+ /**
90
+ * Add a user to an org by email (owner-only). `org_id`/`role`/`user_id` are NEVER body-bindable —
91
+ * the org is the server-derived tenant and the added user always joins as a plain `member`. Only
92
+ * the email is client-supplied; it is normalized server-side before any lookup/write.
93
+ */
94
+ export const AddOrgMemberRequest = z.object({ email: emailField });
95
+ /** One member of an org (the id + email + role) — never a hash/secret. */
96
+ export const OrgMemberView = z.object({
97
+ userId: z.string().uuid(),
98
+ email: z.string(),
99
+ role: Role,
100
+ });
101
+ /**
102
+ * The add-member result. `oneTimePassword` is a DELIBERATE, single-shot secret-in-a-response — it
103
+ * is populated ONLY when the added email had no existing user, so a fresh account is provisioned
104
+ * with a random initial password the operator conveys out-of-band (core has no outbound mail). It
105
+ * is shown EXACTLY ONCE, never stored in plaintext, and never returned again (precedent:
106
+ * `MintApiKeyResponse.plaintext`). Absent when an existing user was added.
107
+ */
108
+ export const AddOrgMemberResponse = z.object({
109
+ userId: z.string().uuid(),
110
+ email: z.string(),
111
+ role: Role,
112
+ oneTimePassword: z.string().optional(),
113
+ });
114
+ export const OrgMemberListResponse = z.object({ members: z.array(OrgMemberView) });
115
+ // ---- org invites (out-of-band invite-token flow) -------------------------------------------
116
+ /**
117
+ * Issue an out-of-band org invite (owner-only). `org_id`/`tenant_id` are NEVER body-bindable — the
118
+ * org is the server-derived tenant. `email` is normalized server-side. `role` defaults to `member`.
119
+ * `expiresInSeconds` is an OPTIONAL requested lifetime clamped server-side to a floor/ceiling.
120
+ *
121
+ * The issue path deliberately does NOT look up whether the email has an account, so the response +
122
+ * timing are IDENTICAL whether or not it does — no account-existence oracle (the account-existence
123
+ * check happens only at redeem, performed by the invitee).
124
+ */
125
+ export const IssueInviteRequest = z.object({
126
+ email: emailField,
127
+ role: Role.default('member'),
128
+ expiresInSeconds: z.number().int().positive().optional(),
129
+ });
130
+ /**
131
+ * The issued invite. `inviteToken` is a DELIBERATE, single-shot secret-in-a-response — the opaque
132
+ * token is shown EXACTLY ONCE (the owner conveys it out-of-band; only its hash is stored), never
133
+ * returned again (precedent: `MintApiKeyResponse.plaintext`). The response carries NO account-existence
134
+ * signal (it is identical for any email).
135
+ */
136
+ export const IssueInviteResponse = z.object({
137
+ /** The opaque invite token, shown EXACTLY ONCE; never stored in plaintext, never returned again. */
138
+ inviteToken: z.string(),
139
+ email: z.string(),
140
+ role: Role,
141
+ /** The hard expiry (ISO 8601). */
142
+ expiresAt: z.string(),
143
+ });
144
+ /**
145
+ * Redeem an invite (the INVITEE acts, not the owner). The org is resolved FROM the token, never a URL.
146
+ * `password` is REQUIRED only when the invited email has no account yet (the invitee sets their own
147
+ * initial credential at accept); it is IGNORED when the email already has an account (the invitee must
148
+ * instead be authenticated as that account — a token bearer can never set/reset an existing user's
149
+ * password). `password` shares the register/login shape policy.
150
+ */
151
+ export const AcceptInviteRequest = z.object({
152
+ token: z.string().min(1),
153
+ password: passwordField.optional(),
154
+ deliverRefreshTokenInBody,
155
+ });
156
+ /**
157
+ * The accept result — a token envelope scoped to the joined org (so the response is immediately
158
+ * usable), plus the membership. `refreshToken` is the gated+opt-in body-delivered refresh secret,
159
+ * present ONLY on the new-account provisioning path (the invitee is freshly logged in) when the
160
+ * deployment enables it; an authenticated existing-account accept receives only a fresh org-scoped
161
+ * access token (its refresh session is untouched).
162
+ */
163
+ export const AcceptInviteResponse = z.object({
164
+ accessToken: z.string(),
165
+ tokenType: z.literal('Bearer'),
166
+ expiresIn: z.number().int().positive(),
167
+ activeOrgId: z.string().uuid(),
168
+ userId: z.string().uuid(),
169
+ role: Role,
170
+ /** The rotated refresh secret — gated+opt-in, new-account path only (see the type doc above). */
171
+ refreshToken: z.string().optional(),
172
+ });
173
+ // ---- api keys ------------------------------------------------------------------------------
174
+ /**
175
+ * The known api-key scopes (closed set; intersected with requested on mint). `store:read`/
176
+ * `store:write` let a programmatic/agency consumer (a desktop app / automation) READ
177
+ * and WRITE declared-store data via an org-scoped api-key — the deployer grants the write scope
178
+ * EXPLICITLY at mint (it is not implicit).
179
+ *
180
+ * DERIVED from `API_KEY_GRANTABLE` (authz.ts) — the SINGLE source of truth shared with `authorize()`.
181
+ * This is not a parallel literal that can silently drift from the runtime gate: a scope minted here
182
+ * that authorize() would never grant (or vice-versa) is structurally impossible, because both read
183
+ * the same array. (The authz.test.ts derived-invariant test additionally pins the equality.)
184
+ */
185
+ export const ApiKeyScope = z.enum(API_KEY_GRANTABLE);
186
+ export const MintApiKeyRequest = z.object({
187
+ name: z.string().min(1).max(200).optional(),
188
+ scopes: z.array(ApiKeyScope).max(32).default([]),
189
+ });
190
+ /** The mint response is the ONLY place plaintext is returned — ONCE. */
191
+ export const MintApiKeyResponse = z.object({
192
+ id: z.string().uuid(),
193
+ keyPrefix: z.string(),
194
+ /** The full plaintext key, shown EXACTLY ONCE; never stored, never returned again. */
195
+ plaintext: z.string(),
196
+ scopes: z.array(ApiKeyScope),
197
+ });
198
+ /**
199
+ * The REDACTED mint snapshot — the ONLY shape persisted in idempotency_keys.snapshot and the
200
+ * shape returned on an Idempotency-Key REPLAY. It NEVER carries
201
+ * `plaintext`: the secret is shown EXACTLY ONCE on the original mint; a retry returns only the
202
+ * non-secret metadata so a DB dump of the no-TTL snapshot column yields no usable `<prefix>.<secret>`
203
+ * credential. `replayed: true` signals the caller that the plaintext is not available again.
204
+ */
205
+ export const MintApiKeyReplay = z.object({
206
+ id: z.string().uuid(),
207
+ keyPrefix: z.string(),
208
+ scopes: z.array(ApiKeyScope),
209
+ replayed: z.literal(true),
210
+ });
211
+ /** Listing never includes plaintext or the hash. */
212
+ export const ApiKeyView = z.object({
213
+ id: z.string().uuid(),
214
+ keyPrefix: z.string(),
215
+ scopes: z.array(z.string()),
216
+ lastUsedAt: z.string().nullable(),
217
+ revokedAt: z.string().nullable(),
218
+ createdAt: z.string(),
219
+ });
220
+ export const ApiKeyListResponse = z.object({ keys: z.array(ApiKeyView) });
221
+ //# sourceMappingURL=dto.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dto.js","sourceRoot":"","sources":["../src/dto.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Email normalization — applied BEFORE every users.email write so the partial unique index on
3
+ * lower(email) cannot be bypassed by compatibility/whitespace/invisible-character variants, and
4
+ * the dummy-hash enumeration defense operates on a single canonical form.
5
+ *
6
+ * Steps: trim, Unicode NFKC fold, lowercase, cap at 254 chars (RFC 5321), then reject any
7
+ * embedded Unicode Control/Format (\p{C}) or Separator/whitespace (\p{Z}) character anywhere in
8
+ * the address. NOT a deliverability validator — a normalization + basic shape guard. Throws on
9
+ * a structurally invalid address so a bad value never reaches the DB.
10
+ *
11
+ * SCOPE / LIMITATION (honest): NFKC collapses COMPATIBILITY variants (e.g. fullwidth ALICE ->
12
+ * alice) and the \p{C}/\p{Z}/\p{Default_Ignorable_Code_Point} reject closes the invisible-character
13
+ * class (zero-width space/joiner/non-joiner, word-joiner, soft-hyphen, BOM, LRM/RLO bidi marks,
14
+ * NBSP, AND the Default_Ignorable code points \p{C} misses — variation selectors U+FE00–FE0F /
15
+ * U+E0100–E01EF, Mongolian free variation selectors, etc., which are category Mn/format-invisible
16
+ * and would otherwise survive NFKC) — these all PASSED the previous ASCII-only guard and the
17
+ * earlier \p{C}/\p{Z}-only guard. It does NOT defend against cross-script HOMOGLYPHS: a Cyrillic
18
+ * "а" (U+0430) is a distinct letter that NFKC does not fold and \p{C}/\p{Z} does not catch, so
19
+ * "аlice@x.com" and "alice@x.com" remain different canonical strings. Full TR39 confusable-
20
+ * skeleton / IDNA defense is deliberately out of scope (it needs a confusables table);
21
+ * the homograph test below documents this boundary so the limitation is explicit, not implied.
22
+ */
23
+ export declare function normalizeEmail(raw: string): string;
24
+ //# sourceMappingURL=email.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"email.d.ts","sourceRoot":"","sources":["../src/email.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAWH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAYlD"}
package/dist/email.js ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Email normalization — applied BEFORE every users.email write so the partial unique index on
3
+ * lower(email) cannot be bypassed by compatibility/whitespace/invisible-character variants, and
4
+ * the dummy-hash enumeration defense operates on a single canonical form.
5
+ *
6
+ * Steps: trim, Unicode NFKC fold, lowercase, cap at 254 chars (RFC 5321), then reject any
7
+ * embedded Unicode Control/Format (\p{C}) or Separator/whitespace (\p{Z}) character anywhere in
8
+ * the address. NOT a deliverability validator — a normalization + basic shape guard. Throws on
9
+ * a structurally invalid address so a bad value never reaches the DB.
10
+ *
11
+ * SCOPE / LIMITATION (honest): NFKC collapses COMPATIBILITY variants (e.g. fullwidth ALICE ->
12
+ * alice) and the \p{C}/\p{Z}/\p{Default_Ignorable_Code_Point} reject closes the invisible-character
13
+ * class (zero-width space/joiner/non-joiner, word-joiner, soft-hyphen, BOM, LRM/RLO bidi marks,
14
+ * NBSP, AND the Default_Ignorable code points \p{C} misses — variation selectors U+FE00–FE0F /
15
+ * U+E0100–E01EF, Mongolian free variation selectors, etc., which are category Mn/format-invisible
16
+ * and would otherwise survive NFKC) — these all PASSED the previous ASCII-only guard and the
17
+ * earlier \p{C}/\p{Z}-only guard. It does NOT defend against cross-script HOMOGLYPHS: a Cyrillic
18
+ * "а" (U+0430) is a distinct letter that NFKC does not fold and \p{C}/\p{Z} does not catch, so
19
+ * "аlice@x.com" and "alice@x.com" remain different canonical strings. Full TR39 confusable-
20
+ * skeleton / IDNA defense is deliberately out of scope (it needs a confusables table);
21
+ * the homograph test below documents this boundary so the limitation is explicit, not implied.
22
+ */
23
+ // Reject Unicode Control/Format chars (\p{C} — incl. zero-width/format/bidi marks), any
24
+ // Separator/whitespace (\p{Z} + ASCII control/whitespace via \s), AND every other
25
+ // Default_Ignorable code point — notably the variation selectors U+FE00–FE0F and U+E0100–E01EF,
26
+ // which are category Mn (a Mark, NOT \p{C}) yet render invisibly and survive NFKC, so a
27
+ // \p{C}/\p{Z}-only guard let them through and they could defeat the lower(email) unique index.
28
+ // NFKC keeps all of these (it does not strip them), so they must be rejected explicitly. The /u
29
+ // flag is required for \p{...}. Built so the source carries no literal invisible bytes.
30
+ const DISALLOWED = /[\p{C}\p{Z}\p{Default_Ignorable_Code_Point}\s]/u;
31
+ export function normalizeEmail(raw) {
32
+ const normalized = raw.trim().normalize('NFKC').toLowerCase();
33
+ if (normalized.length === 0)
34
+ throw new Error('email is empty');
35
+ if (normalized.length > 254)
36
+ throw new Error('email exceeds 254 characters');
37
+ if (DISALLOWED.test(normalized)) {
38
+ throw new Error('email contains control, whitespace, or invisible characters');
39
+ }
40
+ const at = normalized.indexOf('@');
41
+ if (at <= 0 || at !== normalized.lastIndexOf('@') || at === normalized.length - 1) {
42
+ throw new Error('email is not a valid address');
43
+ }
44
+ return normalized;
45
+ }
46
+ //# sourceMappingURL=email.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"email.js","sourceRoot":"","sources":["../src/email.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,wFAAwF;AACxF,kFAAkF;AAClF,gGAAgG;AAChG,wFAAwF;AACxF,+FAA+F;AAC/F,gGAAgG;AAChG,wFAAwF;AACxF,MAAM,UAAU,GAAG,iDAAiD,CAAC;AAErE,MAAM,UAAU,cAAc,CAAC,GAAW;IACxC,MAAM,UAAU,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,WAAW,EAAE,CAAC;IAC9D,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC;QAAE,MAAM,IAAI,KAAK,CAAC,gBAAgB,CAAC,CAAC;IAC/D,IAAI,UAAU,CAAC,MAAM,GAAG,GAAG;QAAE,MAAM,IAAI,KAAK,CAAC,8BAA8B,CAAC,CAAC;IAC7E,IAAI,UAAU,CAAC,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC;QAChC,MAAM,IAAI,KAAK,CAAC,6DAA6D,CAAC,CAAC;IACjF,CAAC;IACD,MAAM,EAAE,GAAG,UAAU,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IACnC,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,KAAK,UAAU,CAAC,WAAW,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,UAAU,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAClF,MAAM,IAAI,KAAK,CAAC,8BAA8B,CAAC,CAAC;IAClD,CAAC;IACD,OAAO,UAAU,CAAC;AACpB,CAAC"}