@nxgt/janus 0.1.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 (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +554 -0
  3. package/dist/auth/config.d.ts +201 -0
  4. package/dist/auth/config.d.ts.map +1 -0
  5. package/dist/auth/context.d.ts +87 -0
  6. package/dist/auth/context.d.ts.map +1 -0
  7. package/dist/auth/hashers.d.ts +35 -0
  8. package/dist/auth/hashers.d.ts.map +1 -0
  9. package/dist/auth/index.d.ts +9 -0
  10. package/dist/auth/index.d.ts.map +1 -0
  11. package/dist/auth/janus.d.ts +38 -0
  12. package/dist/auth/janus.d.ts.map +1 -0
  13. package/dist/auth/outage.d.ts +61 -0
  14. package/dist/auth/outage.d.ts.map +1 -0
  15. package/dist/auth/port/assert-stores.d.ts +16 -0
  16. package/dist/auth/port/assert-stores.d.ts.map +1 -0
  17. package/dist/auth/port/memory.d.ts +25 -0
  18. package/dist/auth/port/memory.d.ts.map +1 -0
  19. package/dist/auth/port/types.d.ts +393 -0
  20. package/dist/auth/port/types.d.ts.map +1 -0
  21. package/dist/auth/secrets.d.ts +17 -0
  22. package/dist/auth/secrets.d.ts.map +1 -0
  23. package/dist/auth/sessions.d.ts +31 -0
  24. package/dist/auth/sessions.d.ts.map +1 -0
  25. package/dist/auth/standard-schema.d.ts +41 -0
  26. package/dist/auth/standard-schema.d.ts.map +1 -0
  27. package/dist/auth/types.d.ts +353 -0
  28. package/dist/auth/types.d.ts.map +1 -0
  29. package/dist/auth/users.d.ts +9 -0
  30. package/dist/auth/users.d.ts.map +1 -0
  31. package/dist/chunks/index-658b6mr2.js +269 -0
  32. package/dist/chunks/index-658b6mr2.js.map +11 -0
  33. package/dist/chunks/index-6dytvy3h.js +93 -0
  34. package/dist/chunks/index-6dytvy3h.js.map +11 -0
  35. package/dist/chunks/index-6p56fpbe.js +147 -0
  36. package/dist/chunks/index-6p56fpbe.js.map +12 -0
  37. package/dist/chunks/index-fgb3t64y.js +77 -0
  38. package/dist/chunks/index-fgb3t64y.js.map +10 -0
  39. package/dist/conformance/assert.d.ts +36 -0
  40. package/dist/conformance/assert.d.ts.map +1 -0
  41. package/dist/conformance/cases/outage.d.ts +3 -0
  42. package/dist/conformance/cases/outage.d.ts.map +1 -0
  43. package/dist/conformance/cases/sessions.d.ts +3 -0
  44. package/dist/conformance/cases/sessions.d.ts.map +1 -0
  45. package/dist/conformance/cases/tokens.d.ts +3 -0
  46. package/dist/conformance/cases/tokens.d.ts.map +1 -0
  47. package/dist/conformance/cases/users.d.ts +3 -0
  48. package/dist/conformance/cases/users.d.ts.map +1 -0
  49. package/dist/conformance/describe.d.ts +74 -0
  50. package/dist/conformance/describe.d.ts.map +1 -0
  51. package/dist/conformance/fixtures.d.ts +11 -0
  52. package/dist/conformance/fixtures.d.ts.map +1 -0
  53. package/dist/conformance/index.d.ts +33 -0
  54. package/dist/conformance/index.d.ts.map +1 -0
  55. package/dist/conformance/index.js +1113 -0
  56. package/dist/conformance/index.js.map +18 -0
  57. package/dist/conformance/reference.d.ts +13 -0
  58. package/dist/conformance/reference.d.ts.map +1 -0
  59. package/dist/conformance/relations.d.ts +67 -0
  60. package/dist/conformance/relations.d.ts.map +1 -0
  61. package/dist/conformance/types.d.ts +71 -0
  62. package/dist/conformance/types.d.ts.map +1 -0
  63. package/dist/errors/janus-error.d.ts +259 -0
  64. package/dist/errors/janus-error.d.ts.map +1 -0
  65. package/dist/ids/id.d.ts +55 -0
  66. package/dist/ids/id.d.ts.map +1 -0
  67. package/dist/index.d.ts +30 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +962 -0
  70. package/dist/index.js.map +19 -0
  71. package/dist/pagination/cursor-page.d.ts +53 -0
  72. package/dist/pagination/cursor-page.d.ts.map +1 -0
  73. package/dist/permissions/engine.d.ts +46 -0
  74. package/dist/permissions/engine.d.ts.map +1 -0
  75. package/dist/permissions/index.d.ts +24 -0
  76. package/dist/permissions/index.d.ts.map +1 -0
  77. package/dist/permissions/index.js +685 -0
  78. package/dist/permissions/index.js.map +15 -0
  79. package/dist/permissions/input.d.ts +29 -0
  80. package/dist/permissions/input.d.ts.map +1 -0
  81. package/dist/permissions/model.d.ts +368 -0
  82. package/dist/permissions/model.d.ts.map +1 -0
  83. package/dist/permissions/port/memory.d.ts +17 -0
  84. package/dist/permissions/port/memory.d.ts.map +1 -0
  85. package/dist/permissions/port/types.d.ts +84 -0
  86. package/dist/permissions/port/types.d.ts.map +1 -0
  87. package/dist/permissions/resolve.d.ts +59 -0
  88. package/dist/permissions/resolve.d.ts.map +1 -0
  89. package/dist/permissions/reverse.d.ts +53 -0
  90. package/dist/permissions/reverse.d.ts.map +1 -0
  91. package/dist/permissions/walk.d.ts +29 -0
  92. package/dist/permissions/walk.d.ts.map +1 -0
  93. package/dist/subjects/notation.d.ts +35 -0
  94. package/dist/subjects/notation.d.ts.map +1 -0
  95. package/dist/subjects/subject.d.ts +75 -0
  96. package/dist/subjects/subject.d.ts.map +1 -0
  97. package/dist/time/clock.d.ts +31 -0
  98. package/dist/time/clock.d.ts.map +1 -0
  99. package/dist/time/duration.d.ts +23 -0
  100. package/dist/time/duration.d.ts.map +1 -0
  101. package/docs/README.md +17 -0
  102. package/docs/guide/adapters.md +296 -0
  103. package/docs/guide/email-flows.md +146 -0
  104. package/docs/guide/errors.md +146 -0
  105. package/docs/guide/passwords.md +136 -0
  106. package/docs/guide/permissions.md +347 -0
  107. package/docs/guide/sessions.md +211 -0
  108. package/docs/guide/users.md +277 -0
  109. package/docs/guide/vocabulary.md +163 -0
  110. package/docs/roadmap.md +93 -0
  111. package/docs/troubleshooting.md +648 -0
  112. package/package.json +70 -0
package/dist/index.js ADDED
@@ -0,0 +1,962 @@
1
+ import {
2
+ mintId2,
3
+ isId2,
4
+ mintedAt2,
5
+ createMemoryStores2
6
+ } from "./chunks/index-658b6mr2.js";
7
+ import {
8
+ guardStores,
9
+ guardRelations,
10
+ required,
11
+ unlessVersionConflict,
12
+ DEFAULT_PAGE_SIZE2,
13
+ MAX_PAGE_SIZE2,
14
+ pageLimit2,
15
+ invalidCursor2
16
+ } from "./chunks/index-6dytvy3h.js";
17
+ import {
18
+ JanusError2,
19
+ StoreFailure2,
20
+ StoreConflict2,
21
+ NotFoundError2,
22
+ UserInvalidError2,
23
+ CredentialError2,
24
+ UserInactiveError2,
25
+ TokenError2,
26
+ InvalidCursorError2,
27
+ UnsupportedError2,
28
+ PermissionDepthError2,
29
+ isSubjectSet2,
30
+ subjectOf2,
31
+ formatEntity2,
32
+ formatSubject2,
33
+ formatTuple2,
34
+ parseTuple2,
35
+ parseSubject2
36
+ } from "./chunks/index-6p56fpbe.js";
37
+
38
+ // src/auth/hashers.ts
39
+ import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
40
+ function scryptHasher(options) {
41
+ const cost = options?.cost ?? 17;
42
+ if (!Number.isInteger(cost) || cost < 10 || cost > 20) {
43
+ throw new TypeError("scryptHasher: cost is log2(N), an integer from 10 to 20 — 17 is the recommended value");
44
+ }
45
+ return {
46
+ prefix: SCRYPT_PREFIX,
47
+ async hash(plain) {
48
+ const salt = randomBytes(16);
49
+ const key = await derive(plain, salt, cost, 8, 1);
50
+ return `${SCRYPT_PREFIX}ln=${cost},r=8,p=1$${salt.toString("base64")}$${key.toString("base64")}`;
51
+ },
52
+ async verify(plain, hash) {
53
+ const parsed = SCRYPT_HASH.exec(hash);
54
+ if (parsed === null) {
55
+ throw new TypeError("scryptHasher: the stored hash has the $scrypt$ prefix and not its format");
56
+ }
57
+ const [, ln, r, p, salt, key] = parsed;
58
+ const expected = Buffer.from(key, "base64");
59
+ const actual = await derive(plain, Buffer.from(salt, "base64"), Number(ln), Number(r), Number(p), expected.length);
60
+ return timingSafeEqual(actual, expected);
61
+ },
62
+ needsRehash(hash) {
63
+ const parsed = SCRYPT_HASH.exec(hash);
64
+ if (parsed === null)
65
+ return false;
66
+ const [, ln, r, p] = parsed;
67
+ return Number(ln) !== cost || r !== "8" || p !== "1";
68
+ }
69
+ };
70
+ }
71
+ var SCRYPT_PREFIX = "$scrypt$";
72
+ var SCRYPT_HASH = /^\$scrypt\$ln=(\d+),r=(\d+),p=(\d+)\$([A-Za-z0-9+/=]+)\$([A-Za-z0-9+/=]+)$/;
73
+ function derive(plain, salt, ln, r, p, length = 32) {
74
+ const n = 2 ** ln;
75
+ return new Promise((resolve, reject) => {
76
+ scrypt(plain, salt, length, { cost: n, blockSize: r, parallelization: p, maxmem: 256 * n * r }, (error, key) => error ? reject(error) : resolve(key));
77
+ });
78
+ }
79
+ function bunHasher() {
80
+ const password = typeof Bun === "undefined" ? undefined : Bun.password;
81
+ if (password === undefined) {
82
+ throw new TypeError("bunHasher: Bun.password is not available — this runtime is not Bun; wire scryptHasher() instead");
83
+ }
84
+ return {
85
+ prefix: "$argon2id$",
86
+ hash: (plain) => password.hash(plain, ARGON2ID),
87
+ verify: (plain, hash) => password.verify(plain, hash),
88
+ needsRehash: (hash) => !hash.startsWith(ARGON2ID_PARAMETERS)
89
+ };
90
+ }
91
+ var ARGON2ID = {
92
+ algorithm: "argon2id",
93
+ memoryCost: 65536,
94
+ timeCost: 2
95
+ };
96
+ var ARGON2ID_PARAMETERS = "$argon2id$v=19$m=65536,t=2,p=1$";
97
+ // src/time/clock.ts
98
+ var systemClock = {
99
+ now: () => new Date
100
+ };
101
+ function fixedClock(start = 0) {
102
+ let ms = start instanceof Date ? start.getTime() : start;
103
+ return {
104
+ now: () => new Date(ms),
105
+ advance: (by) => {
106
+ ms += by;
107
+ },
108
+ set: (at) => {
109
+ ms = at instanceof Date ? at.getTime() : at;
110
+ }
111
+ };
112
+ }
113
+
114
+ // src/time/duration.ts
115
+ var UNITS = {
116
+ ms: 1,
117
+ s: 1000,
118
+ m: 60000,
119
+ h: 3600000,
120
+ d: 86400000
121
+ };
122
+ var PATTERN = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/;
123
+ function parseDuration(value, where) {
124
+ if (typeof value === "number") {
125
+ if (!Number.isFinite(value) || value <= 0) {
126
+ throw new TypeError(`${where}: a duration in milliseconds must be a finite number above zero`);
127
+ }
128
+ return value;
129
+ }
130
+ const match = PATTERN.exec(value);
131
+ if (!match) {
132
+ throw new TypeError(`${where}: "${value}" is not a duration; write a number followed by ms, s, m, h or d — for example "15m" or "720h"`);
133
+ }
134
+ const amount = Number(match[1]);
135
+ const unit = match[2];
136
+ const ms = amount * UNITS[unit];
137
+ if (ms <= 0) {
138
+ throw new TypeError(`${where}: a duration must be above zero`);
139
+ }
140
+ return ms;
141
+ }
142
+
143
+ // src/auth/config.ts
144
+ var SINGLE_TYPE = "user";
145
+ var RESERVED_FIELDS = [
146
+ "id",
147
+ "type",
148
+ "emailVerified",
149
+ "active",
150
+ "hasPassword",
151
+ "version",
152
+ "createdAt",
153
+ "updatedAt",
154
+ "password"
155
+ ];
156
+ var RESERVED_TYPES = [
157
+ "authenticate",
158
+ "signOut",
159
+ "signOutEverywhere",
160
+ "findUser",
161
+ "getUser",
162
+ "cookie",
163
+ "collectExpired",
164
+ "types"
165
+ ];
166
+ var NORMALIZERS = {
167
+ none: (value) => value,
168
+ lowercase: (value) => value.toLowerCase(),
169
+ lowercaseTrim: (value) => value.toLowerCase().trim(),
170
+ nfkcLowercaseTrim: (value) => value.normalize("NFKC").toLowerCase().trim()
171
+ };
172
+ var normalizeEmail = NORMALIZERS.lowercaseTrim;
173
+ var COOKIE_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
174
+ var NAME = /^[A-Za-z][A-Za-z0-9]*$/;
175
+ function resolveConfig(config, where) {
176
+ if (typeof config !== "object" || config === null) {
177
+ throw new TypeError(`${where}: expected a configuration object`);
178
+ }
179
+ const hasUser = config.user !== undefined;
180
+ const hasUsers = config.users !== undefined;
181
+ if (hasUser === hasUsers) {
182
+ throw new TypeError(`${where}: pass either user (one kind of user) or users (several kinds), and exactly one of them`);
183
+ }
184
+ const entries = hasUsers ? Object.entries(config.users) : [
185
+ [
186
+ SINGLE_TYPE,
187
+ {
188
+ ...config,
189
+ schema: config.user
190
+ }
191
+ ]
192
+ ];
193
+ if (entries.length === 0) {
194
+ throw new TypeError(`${where}: users declares no user type`);
195
+ }
196
+ const types = new Map;
197
+ for (const [name, type] of entries) {
198
+ const at = hasUsers ? `${where}: users.${name}` : where;
199
+ if (!NAME.test(name)) {
200
+ throw new TypeError(`${where}: the user type "${name}" must be a camelCase name — letters and digits, starting with a letter`);
201
+ }
202
+ if (RESERVED_TYPES.includes(name)) {
203
+ throw new TypeError(`${where}: "${name}" cannot name a user type — janus() answers a method of that name`);
204
+ }
205
+ types.set(name, resolveType(name, type, at, hasUsers ? "schema" : "user"));
206
+ }
207
+ const hashing = [...types.values()].some((type) => type.password !== null);
208
+ if (hashing && config.hasher === undefined) {
209
+ throw new TypeError(`${where}: a user type signs in with a password and no hasher is wired — pass hasher: scryptHasher(), or bunHasher() on Bun. There is no silent fallback`);
210
+ }
211
+ return {
212
+ single: !hasUsers,
213
+ types,
214
+ tokenTtlMs: {
215
+ verifyEmail: parseDuration(config.tokens?.verifyEmail ?? "24h", `${where}: tokens.verifyEmail`),
216
+ resetPassword: parseDuration(config.tokens?.resetPassword ?? "1h", `${where}: tokens.resetPassword`)
217
+ },
218
+ cookie: resolveCookie(config.cookie ?? {}, where)
219
+ };
220
+ }
221
+ function resolveType(name, type, at, schemaKey) {
222
+ if (typeof type?.schema !== "object" || type.schema === null || typeof type.schema["~standard"]?.validate !== "function") {
223
+ throw new TypeError(`${at}: ${schemaKey} must be a Standard Schema — a Zod 4, Valibot or ArkType schema`);
224
+ }
225
+ let password = null;
226
+ if (type.password !== undefined) {
227
+ const minLength = type.password.minLength ?? 8;
228
+ if (!Number.isInteger(minLength) || minLength < 1) {
229
+ throw new TypeError(`${at}: password.minLength must be an integer of at least 1`);
230
+ }
231
+ password = {
232
+ login: fieldName(type.password.login, `${at}: password.login`),
233
+ normalize: normalizer(type.password.normalize ?? "lowercaseTrim", `${at}: password.normalize`),
234
+ minLength
235
+ };
236
+ }
237
+ const renewAfter = type.session?.renewAfter ?? "1d";
238
+ return {
239
+ name,
240
+ schema: type.schema,
241
+ schemaVersion: type.schemaVersion ?? "1",
242
+ password,
243
+ email: fieldName(type.email ?? "email", `${at}: email`),
244
+ lifespanMs: parseDuration(type.session?.lifespan ?? "7d", `${at}: session.lifespan`),
245
+ renewAfterMs: renewAfter === false ? null : parseDuration(renewAfter, `${at}: session.renewAfter`)
246
+ };
247
+ }
248
+ function resolveCookie(cookie, where) {
249
+ const name = cookie.name ?? "janus-session";
250
+ if (!COOKIE_NAME.test(name)) {
251
+ throw new TypeError(`${where}: cookie.name must be a cookie-name token — letters, digits and !#$%&'*+-.^_\`|~, with no space, ";" or "="`);
252
+ }
253
+ const sameSite = cookie.sameSite ?? "lax";
254
+ const secure = cookie.secure ?? true;
255
+ if (sameSite === "none" && !secure) {
256
+ throw new TypeError(`${where}: cookie.sameSite "none" requires cookie.secure — browsers refuse the cookie otherwise`);
257
+ }
258
+ return {
259
+ name,
260
+ domain: cookie.domain ?? null,
261
+ path: cookie.path ?? "/",
262
+ sameSite,
263
+ secure
264
+ };
265
+ }
266
+ function fieldName(field, where) {
267
+ if (typeof field !== "string" || !NAME.test(field)) {
268
+ throw new TypeError(`${where} must name a top-level field of the schema, such as "email"`);
269
+ }
270
+ if (RESERVED_FIELDS.includes(field)) {
271
+ throw new TypeError(`${where}: "${field}" is a field janus sets itself`);
272
+ }
273
+ return field;
274
+ }
275
+ function normalizer(rule, where) {
276
+ if (typeof rule === "function")
277
+ return rule;
278
+ if (typeof rule === "string" && Object.hasOwn(NORMALIZERS, rule)) {
279
+ return NORMALIZERS[rule];
280
+ }
281
+ throw new TypeError(`${where} must be "none", "lowercase", "lowercaseTrim", "nfkcLowercaseTrim" or a function`);
282
+ }
283
+
284
+ // src/auth/context.ts
285
+ function createContext(config, store, relations, capabilities, clock, hasher, verifiers) {
286
+ let dummy = null;
287
+ return {
288
+ config,
289
+ store,
290
+ relations,
291
+ capabilities,
292
+ clock,
293
+ hasher,
294
+ verifiers,
295
+ dummyHash: () => {
296
+ if (hasher === null) {
297
+ throw new TypeError("janus: no hasher to compare a dummy hash with");
298
+ }
299
+ dummy ??= hasher.hash(`janus-dummy-${mintId2()}`);
300
+ return dummy;
301
+ }
302
+ };
303
+ }
304
+ function toUser(record) {
305
+ return {
306
+ ...record.fields,
307
+ id: record.id,
308
+ type: record.type,
309
+ emailVerified: record.emailVerifiedAt !== null,
310
+ active: record.active,
311
+ hasPassword: record.password !== null,
312
+ version: record.version,
313
+ createdAt: record.createdAt,
314
+ updatedAt: record.updatedAt
315
+ };
316
+ }
317
+ var idOf = (user) => typeof user === "string" ? user : user.id;
318
+ async function validateFields(type, input, where) {
319
+ const result = await type.schema["~standard"].validate(input);
320
+ const issues = result.issues === undefined ? [] : result.issues.map((issue) => ({
321
+ path: (issue.path ?? []).map(segmentKey),
322
+ message: issue.message
323
+ }));
324
+ if (result.issues === undefined) {
325
+ const value = result.value;
326
+ for (const key of RESERVED_FIELDS) {
327
+ if (Object.hasOwn(value, key)) {
328
+ issues.push({ path: [key], message: "set by janus, not by a request" });
329
+ }
330
+ }
331
+ }
332
+ if (issues.length > 0) {
333
+ throw new UserInvalidError2(`${where}: the fields do not match the ${type.name} schema (${issues.length} issue${issues.length === 1 ? "" : "s"}, at ${issues.map((i) => i.path.join(".") || "(root)").join(", ")})`, { issues, userType: type.name });
334
+ }
335
+ return withoutUndefined(result.value);
336
+ }
337
+ function segmentKey(segment) {
338
+ const key = typeof segment === "object" ? segment.key : segment;
339
+ return typeof key === "number" ? key : String(key);
340
+ }
341
+ function withoutUndefined(value) {
342
+ if (Array.isArray(value))
343
+ return value.map(withoutUndefined);
344
+ if (typeof value === "object" && value !== null) {
345
+ const out = {};
346
+ for (const [key, inner] of Object.entries(value)) {
347
+ if (inner !== undefined)
348
+ out[key] = withoutUndefined(inner);
349
+ }
350
+ return out;
351
+ }
352
+ return value;
353
+ }
354
+ function emailOf(type, fields) {
355
+ const value = fields[type.email];
356
+ return typeof value === "string" ? value : null;
357
+ }
358
+ function loginsOf(type, fields) {
359
+ const logins = new Set;
360
+ if (type.password !== null) {
361
+ const login = fields[type.password.login];
362
+ if (typeof login !== "string") {
363
+ throw new TypeError(`janus: password.login "${type.password.login}" did not name a string in validated ${type.name} fields — it must name a required string field`);
364
+ }
365
+ logins.add(type.password.normalize(login));
366
+ }
367
+ const email = emailOf(type, fields);
368
+ if (email !== null)
369
+ logins.add(normalizeEmail(email));
370
+ return [...logins];
371
+ }
372
+ function checkPassword(type, password, where) {
373
+ const minLength = type.password?.minLength ?? 8;
374
+ if (typeof password !== "string" || password.length < minLength) {
375
+ throw new CredentialError2("PASSWORD_TOO_SHORT", `${where}: the password is shorter than the policy's ${minLength} characters`, { minLength, userType: type.name });
376
+ }
377
+ }
378
+ function requireHasher(context, where) {
379
+ if (context.hasher === null) {
380
+ throw new TypeError(`${where}: no password hasher is wired — pass hasher: scryptHasher(), or bunHasher() on Bun`);
381
+ }
382
+ return context.hasher;
383
+ }
384
+ async function passwordMatches(context, record, password, where) {
385
+ const stored = record.password;
386
+ if (stored === null)
387
+ return false;
388
+ const verifier = context.verifiers.find((candidate) => stored.hash.startsWith(candidate.prefix));
389
+ if (verifier === undefined) {
390
+ const hashPrefix = /^\$[^$]*\$/.exec(stored.hash)?.[0] ?? "(none)";
391
+ throw new CredentialError2("HASH_UNSUPPORTED", `${where}: no wired verifier claims the prefix "${hashPrefix}"`, { hashPrefix, userId: record.id, userType: record.type });
392
+ }
393
+ return verifier.verify(password, stored.hash);
394
+ }
395
+ async function rehashed(context, record, password) {
396
+ const { hasher } = context;
397
+ const stored = record.password;
398
+ if (hasher === null || stored === null)
399
+ return record;
400
+ const stale = !stored.hash.startsWith(hasher.prefix) || hasher.needsRehash?.(stored.hash) === true;
401
+ if (!stale)
402
+ return record;
403
+ const written = await unlessVersionConflict(context.store.users.updateUser(record.id, {
404
+ updatedAt: context.clock.now(),
405
+ password: {
406
+ hash: await hasher.hash(password),
407
+ updatedAt: stored.updatedAt
408
+ }
409
+ }, record.version));
410
+ return written ?? record;
411
+ }
412
+ var notFound = (where, id, type) => new NotFoundError2(`${where}: no ${type ?? "user"} has this id`, type === null ? { userId: id, operation: where } : { userId: id, userType: type, operation: where });
413
+ async function findRecord(context, id, type) {
414
+ if (!isId2(id))
415
+ return null;
416
+ const record = await context.store.users.findUser(id);
417
+ return record !== null && (type === null || record.type === type) ? record : null;
418
+ }
419
+ async function getRecord(context, id, type, where) {
420
+ return required(await findRecord(context, id, type), () => notFound(where, id, type));
421
+ }
422
+ async function writeUser(context, user, type, options, where, patchOf) {
423
+ const id = idOf(user);
424
+ const record = await getRecord(context, id, type.name, where);
425
+ const ifVersion = options?.ifVersion;
426
+ if (ifVersion !== undefined && ifVersion !== record.version) {
427
+ throw new StoreConflict2("version", `${where}: expected version ${ifVersion}, found ${record.version}`, {
428
+ userId: id,
429
+ expectedVersion: ifVersion,
430
+ actualVersion: record.version,
431
+ operation: where
432
+ });
433
+ }
434
+ const now = context.clock.now();
435
+ const patch = await patchOf(record, now);
436
+ return context.store.users.updateUser(record.id, { ...patch, updatedAt: now }, record.version);
437
+ }
438
+
439
+ // src/auth/port/assert-stores.ts
440
+ var REQUIRED = {
441
+ users: [
442
+ "insertUser",
443
+ "findUser",
444
+ "findUserByLogin",
445
+ "listUsers",
446
+ "updateUser",
447
+ "deleteUser"
448
+ ],
449
+ sessions: [
450
+ "insertSession",
451
+ "findSessionByTokenHash",
452
+ "extendSession",
453
+ "revokeSession",
454
+ "revokeUserSessions",
455
+ "deleteUserSessions"
456
+ ],
457
+ tokens: ["insertToken", "consumeToken", "deleteUserTokens"]
458
+ };
459
+ function assertStores(stores, where) {
460
+ if (typeof stores !== "object" || stores === null) {
461
+ throw new TypeError(`${where}: store must be an object with users, sessions and tokens`);
462
+ }
463
+ for (const slot of Object.keys(REQUIRED)) {
464
+ const store = stores[slot];
465
+ if (typeof store !== "object" || store === null) {
466
+ throw new TypeError(`${where}: store.${slot} is missing`);
467
+ }
468
+ for (const method of REQUIRED[slot]) {
469
+ if (typeof store[method] !== "function") {
470
+ throw new TypeError(`${where}: store.${slot} has no method ${method}, which the port requires`);
471
+ }
472
+ }
473
+ }
474
+ const sessions = stores.sessions;
475
+ const collect = sessions.deleteExpiredSessions;
476
+ if (collect !== undefined && typeof collect !== "function") {
477
+ throw new TypeError(`${where}: store.sessions.deleteExpiredSessions must be a function or absent`);
478
+ }
479
+ return { collectExpired: collect !== undefined };
480
+ }
481
+
482
+ // src/auth/secrets.ts
483
+ import { createHash, randomBytes as randomBytes2 } from "node:crypto";
484
+ function mintSecret() {
485
+ return randomBytes2(32).toString("base64url");
486
+ }
487
+ function hashSecret(secret) {
488
+ return createHash("sha256").update(secret).digest("hex");
489
+ }
490
+
491
+ // src/auth/sessions.ts
492
+ function toSession(record) {
493
+ const { tokenHash: _, ...session } = record;
494
+ return session;
495
+ }
496
+ async function openSession(context, type, user) {
497
+ const now = context.clock.now();
498
+ const token = mintSecret();
499
+ const record = {
500
+ id: mintId2(now.getTime()),
501
+ tokenHash: hashSecret(token),
502
+ userId: user.id,
503
+ authenticatedAt: now,
504
+ expiresAt: new Date(now.getTime() + type.lifespanMs),
505
+ revokedAt: null,
506
+ createdAt: now
507
+ };
508
+ await context.store.sessions.insertSession(record);
509
+ return { user: toUser(user), session: toSession(record), token };
510
+ }
511
+ function sharedApi(context) {
512
+ const { store, clock, config } = context;
513
+ return {
514
+ async authenticate(request, options) {
515
+ const token = presentedToken(request, config.cookie.name);
516
+ if (token === null)
517
+ return null;
518
+ const session = await store.sessions.findSessionByTokenHash(hashSecret(token));
519
+ const now = clock.now().getTime();
520
+ if (session === null || session.revokedAt !== null || session.expiresAt.getTime() <= now) {
521
+ return null;
522
+ }
523
+ const user = await store.users.findUser(session.userId);
524
+ if (user === null || !user.active || options?.type !== undefined && user.type !== options.type) {
525
+ return null;
526
+ }
527
+ const type = config.types.get(user.type);
528
+ let current = session;
529
+ let renewed = false;
530
+ if (type !== undefined && type.renewAfterMs !== null && type.lifespanMs - (session.expiresAt.getTime() - now) >= type.renewAfterMs) {
531
+ const extended = await store.sessions.extendSession(session.id, new Date(now + type.lifespanMs));
532
+ if (extended === null)
533
+ return null;
534
+ current = extended;
535
+ renewed = true;
536
+ }
537
+ return {
538
+ user: toUser(user),
539
+ session: toSession(current),
540
+ token,
541
+ renewed
542
+ };
543
+ },
544
+ async signOut(request) {
545
+ const token = presentedToken(request, config.cookie.name);
546
+ if (token === null)
547
+ return false;
548
+ const session = await store.sessions.findSessionByTokenHash(hashSecret(token));
549
+ if (session === null)
550
+ return false;
551
+ return store.sessions.revokeSession(session.id, clock.now());
552
+ },
553
+ async signOutEverywhere(user, options) {
554
+ const id = idOf(user);
555
+ if (!isId2(id))
556
+ return 0;
557
+ return options?.except === undefined ? store.sessions.revokeUserSessions(id, clock.now()) : store.sessions.revokeUserSessions(id, clock.now(), options.except);
558
+ },
559
+ async findUser(id) {
560
+ const record = await findRecord(context, id, null);
561
+ return record === null ? null : toUser(record);
562
+ },
563
+ async getUser(id) {
564
+ return toUser(await getRecord(context, id, null, "getUser"));
565
+ },
566
+ cookie: cookieOf(context),
567
+ async collectExpired() {
568
+ const collect = store.sessions.deleteExpiredSessions;
569
+ if (!context.capabilities.collectExpired || collect === undefined) {
570
+ throw new UnsupportedError2("collectExpired: store.sessions does not implement deleteExpiredSessions — its store expires sessions on its own, or implement the method", { slot: "sessions", operation: "deleteExpiredSessions" });
571
+ }
572
+ return collect(clock.now());
573
+ },
574
+ types: [...config.types.keys()]
575
+ };
576
+ }
577
+ function cookieOf(context) {
578
+ const { name, domain, path, sameSite, secure } = context.config.cookie;
579
+ const attributes = [
580
+ `Path=${path}`,
581
+ ...domain === null ? [] : [`Domain=${domain}`],
582
+ "HttpOnly",
583
+ `SameSite=${sameSite[0]?.toUpperCase()}${sameSite.slice(1)}`,
584
+ ...secure ? ["Secure"] : []
585
+ ].join("; ");
586
+ return {
587
+ name,
588
+ serialize: (token, session) => `${name}=${token}; Expires=${session.expiresAt.toUTCString()}; ${attributes}`,
589
+ clear: () => `${name}=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; ${attributes}`
590
+ };
591
+ }
592
+ function presentedToken(request, cookieName) {
593
+ const headers = headersOf(request);
594
+ const read = (name) => {
595
+ if (headers instanceof Headers)
596
+ return headers.get(name);
597
+ for (const [key, value] of Object.entries(headers)) {
598
+ if (key.toLowerCase() !== name || value === undefined)
599
+ continue;
600
+ return typeof value === "string" ? value : value.join(", ");
601
+ }
602
+ return null;
603
+ };
604
+ const authorization = read("authorization");
605
+ if (authorization !== null) {
606
+ const match = /^bearer(?:\s+(.*))?$/i.exec(authorization.trim());
607
+ if (match)
608
+ return match[1]?.trim() ?? "";
609
+ }
610
+ const header = read("x-session-token");
611
+ if (header !== null)
612
+ return header.trim();
613
+ const cookie = read("cookie");
614
+ if (cookie !== null) {
615
+ for (const pair of cookie.split(";")) {
616
+ const at = pair.indexOf("=");
617
+ if (at !== -1 && pair.slice(0, at).trim() === cookieName) {
618
+ return pair.slice(at + 1).trim();
619
+ }
620
+ }
621
+ }
622
+ return null;
623
+ }
624
+ function headersOf(request) {
625
+ if (request instanceof Headers)
626
+ return request;
627
+ const inner = request.headers;
628
+ if (inner instanceof Headers)
629
+ return inner;
630
+ if (typeof inner === "object" && inner !== null)
631
+ return inner;
632
+ return request;
633
+ }
634
+
635
+ // src/auth/users.ts
636
+ function typeApi(context, type) {
637
+ const { store, clock } = context;
638
+ const at = (operation) => context.config.single ? operation : `${type.name}.${operation}`;
639
+ const insert = async (input, where) => {
640
+ const { password, active, ...rest } = input ?? {};
641
+ const now = clock.now();
642
+ const fields = await validateFields(type, rest, where);
643
+ let hash = null;
644
+ if (password !== undefined) {
645
+ checkPassword(type, password, where);
646
+ hash = {
647
+ hash: await requireHasher(context, where).hash(password),
648
+ updatedAt: now
649
+ };
650
+ }
651
+ return store.users.insertUser({
652
+ id: mintId2(now.getTime()),
653
+ type: type.name,
654
+ schemaVersion: type.schemaVersion,
655
+ active: active === undefined ? true : active === true,
656
+ fields,
657
+ logins: loginsOf(type, fields),
658
+ password: hash,
659
+ emailVerifiedAt: null,
660
+ version: 0,
661
+ createdAt: now,
662
+ updatedAt: now
663
+ });
664
+ };
665
+ const redeem = async (kind, secret, where) => {
666
+ const now = clock.now();
667
+ const token = await store.tokens.consumeToken(hashSecret(secret), kind, now);
668
+ if (token === null) {
669
+ throw new TokenError2("TOKEN_UNKNOWN", `${where}: no such token`, {
670
+ operation: where
671
+ });
672
+ }
673
+ if (token.spentAt !== null) {
674
+ throw new TokenError2("TOKEN_SPENT", `${where}: the token was already used`, {
675
+ operation: where
676
+ });
677
+ }
678
+ if (token.expiresAt.getTime() <= now.getTime()) {
679
+ throw new TokenError2("TOKEN_EXPIRED", `${where}: the token has expired`, {
680
+ operation: where
681
+ });
682
+ }
683
+ const user = await findRecord(context, token.userId, type.name);
684
+ if (user === null) {
685
+ throw new TokenError2("TOKEN_UNKNOWN", `${where}: no such token`, {
686
+ operation: where
687
+ });
688
+ }
689
+ const email = emailOf(type, user.fields);
690
+ if (email === null || normalizeEmail(email) !== normalizeEmail(token.address)) {
691
+ throw new TokenError2("TOKEN_STALE", `${where}: the token was sent to an e-mail the user no longer has`, { operation: where, userId: user.id, userType: type.name });
692
+ }
693
+ return { token, user };
694
+ };
695
+ const issue = async (kind, user, where) => {
696
+ const email = emailOf(type, user.fields);
697
+ if (email === null) {
698
+ throw new NotFoundError2(`${where}: the user has no e-mail`, {
699
+ userId: user.id,
700
+ userType: type.name,
701
+ operation: where
702
+ });
703
+ }
704
+ const now = clock.now();
705
+ const secret = mintSecret();
706
+ const expiresAt = new Date(now.getTime() + context.config.tokenTtlMs[kind]);
707
+ await store.tokens.insertToken({
708
+ tokenHash: hashSecret(secret),
709
+ kind,
710
+ userId: user.id,
711
+ address: email,
712
+ expiresAt,
713
+ spentAt: null,
714
+ createdAt: now
715
+ });
716
+ return { token: secret, email, expiresAt };
717
+ };
718
+ const passwordRule = (where) => {
719
+ if (type.password === null) {
720
+ throw new TypeError(`${where}: the ${type.name} type does not sign in with a password — add password: { login } to it`);
721
+ }
722
+ return type.password;
723
+ };
724
+ return {
725
+ async create(input) {
726
+ return toUser(await insert(input, at("create")));
727
+ },
728
+ async find(id) {
729
+ const record = await findRecord(context, id, type.name);
730
+ return record === null ? null : toUser(record);
731
+ },
732
+ async get(id) {
733
+ return toUser(await getRecord(context, id, type.name, at("get")));
734
+ },
735
+ async list(page) {
736
+ const after = page?.after ?? null;
737
+ if (after !== null && !isId2(after)) {
738
+ throw invalidCursor2(at("list"), after);
739
+ }
740
+ const answer = await store.users.listUsers({
741
+ type: type.name,
742
+ after,
743
+ limit: pageLimit2(page?.limit, at("list"))
744
+ });
745
+ return { items: answer.items.map(toUser), nextCursor: answer.nextCursor };
746
+ },
747
+ async update(user, patch, options) {
748
+ const where = at("update");
749
+ const written = await writeUser(context, user, type, options, where, async (record) => {
750
+ const fields = await validateFields(type, { ...record.fields, ...patch }, where);
751
+ const before = emailOf(type, record.fields);
752
+ const after = emailOf(type, fields);
753
+ const emailChanged = (before === null ? null : normalizeEmail(before)) !== (after === null ? null : normalizeEmail(after));
754
+ return {
755
+ fields,
756
+ logins: loginsOf(type, fields),
757
+ schemaVersion: type.schemaVersion,
758
+ ...emailChanged ? { emailVerifiedAt: null } : {}
759
+ };
760
+ });
761
+ return toUser(written);
762
+ },
763
+ async setActive(user, active, options) {
764
+ return toUser(await writeUser(context, user, type, options, at("setActive"), () => ({
765
+ active: active === true
766
+ })));
767
+ },
768
+ async delete(user) {
769
+ const id = idOf(user);
770
+ if (!isId2(id))
771
+ return false;
772
+ const record = await store.users.findUser(id);
773
+ if (record !== null && record.type !== type.name)
774
+ return false;
775
+ const deleted = record !== null && await store.users.deleteUser(id);
776
+ await store.sessions.deleteUserSessions(id);
777
+ await store.tokens.deleteUserTokens(id);
778
+ await context.relations?.deleteEntity({ type: type.name, id });
779
+ return deleted;
780
+ },
781
+ async signUp(input) {
782
+ const where = at("signUp");
783
+ passwordRule(where);
784
+ if (input?.password === undefined) {
785
+ checkPassword(type, undefined, where);
786
+ }
787
+ const record = await insert(input, where);
788
+ return openSession(context, type, record);
789
+ },
790
+ async signIn(input) {
791
+ const where = at("signIn");
792
+ const rule = passwordRule(where);
793
+ const hasher = requireHasher(context, where);
794
+ const login = input?.[rule.login];
795
+ const password = input?.password;
796
+ const record = typeof login === "string" ? await store.users.findUserByLogin(type.name, rule.normalize(login)) : null;
797
+ const refuse = (reason) => new CredentialError2("CREDENTIALS_INVALID", `${where}: the login and the password do not match`, { reason, userType: type.name });
798
+ if (record === null || record.password === null) {
799
+ await hasher.verify(String(password), await context.dummyHash());
800
+ throw refuse(record === null ? "unknownLogin" : "noPassword");
801
+ }
802
+ if (!await passwordMatches(context, record, String(password), where)) {
803
+ throw refuse("wrongPassword");
804
+ }
805
+ if (!record.active) {
806
+ throw new UserInactiveError2(`${where}: the user is inactive`, {
807
+ userId: record.id,
808
+ userType: type.name
809
+ });
810
+ }
811
+ return openSession(context, type, await rehashed(context, record, String(password)));
812
+ },
813
+ async findByLogin(login) {
814
+ const rule = passwordRule(at("findByLogin"));
815
+ const record = await store.users.findUserByLogin(type.name, rule.normalize(login));
816
+ return record === null ? null : toUser(record);
817
+ },
818
+ async setPassword(user, password, options) {
819
+ const where = at("setPassword");
820
+ passwordRule(where);
821
+ checkPassword(type, password, where);
822
+ const hash = await requireHasher(context, where).hash(password);
823
+ return toUser(await writeUser(context, user, type, options, where, (_, now) => ({
824
+ password: { hash, updatedAt: now }
825
+ })));
826
+ },
827
+ async changePassword(user, change, options) {
828
+ const where = at("changePassword");
829
+ passwordRule(where);
830
+ checkPassword(type, change?.next, where);
831
+ const hasher = requireHasher(context, where);
832
+ return toUser(await writeUser(context, user, type, options, where, async (record, now) => {
833
+ if (record.password === null || !await passwordMatches(context, record, change.current, where)) {
834
+ throw new CredentialError2("CREDENTIALS_INVALID", `${where}: the current password does not match`, {
835
+ reason: record.password === null ? "noPassword" : "wrongPassword",
836
+ userId: record.id,
837
+ userType: type.name
838
+ });
839
+ }
840
+ return {
841
+ password: {
842
+ hash: await hasher.hash(change.next),
843
+ updatedAt: now
844
+ }
845
+ };
846
+ }));
847
+ },
848
+ verifyEmail: {
849
+ async send(user) {
850
+ const where = at("verifyEmail.send");
851
+ const record = await getRecord(context, idOf(user), type.name, where);
852
+ return issue("verifyEmail", record, where);
853
+ },
854
+ async confirm(secret) {
855
+ const where = at("verifyEmail.confirm");
856
+ const { user } = await redeem("verifyEmail", secret, where);
857
+ return toUser(await writeUser(context, user.id, type, undefined, where, (_, now) => ({
858
+ emailVerifiedAt: now
859
+ })));
860
+ }
861
+ },
862
+ resetPassword: {
863
+ async request(email) {
864
+ const where = at("resetPassword.request");
865
+ passwordRule(where);
866
+ const wanted = normalizeEmail(String(email));
867
+ const record = await store.users.findUserByLogin(type.name, wanted);
868
+ const held = record === null ? null : emailOf(type, record.fields);
869
+ if (record === null || held === null || normalizeEmail(held) !== wanted) {
870
+ return null;
871
+ }
872
+ const issued = await issue("resetPassword", record, where);
873
+ return { ...issued, user: toUser(record) };
874
+ },
875
+ async confirm(secret, password) {
876
+ const where = at("resetPassword.confirm");
877
+ passwordRule(where);
878
+ checkPassword(type, password, where);
879
+ const hash = await requireHasher(context, where).hash(password);
880
+ const { user } = await redeem("resetPassword", secret, where);
881
+ const written = await writeUser(context, user.id, type, undefined, where, (record, now) => ({
882
+ password: { hash, updatedAt: now },
883
+ ...record.emailVerifiedAt === null ? { emailVerifiedAt: now } : {}
884
+ }));
885
+ await store.sessions.revokeUserSessions(written.id, clock.now());
886
+ return toUser(written);
887
+ }
888
+ }
889
+ };
890
+ }
891
+
892
+ // src/auth/janus.ts
893
+ function janus(config) {
894
+ const where = "janus";
895
+ const resolved = resolveConfig(config, where);
896
+ const capabilities = assertStores(config.store, where);
897
+ const hasher = config.hasher ?? null;
898
+ const verifiers = [
899
+ ...hasher === null ? [] : [hasher],
900
+ ...config.verifiers ?? []
901
+ ];
902
+ const prefixes = new Set;
903
+ for (const verifier of verifiers) {
904
+ if (typeof verifier?.prefix !== "string" || verifier.prefix === "") {
905
+ throw new TypeError(`${where}: every hasher needs a non-empty prefix`);
906
+ }
907
+ if (prefixes.has(verifier.prefix)) {
908
+ throw new TypeError(`${where}: two hashers claim the prefix "${verifier.prefix}" — which one verifies would depend on their order`);
909
+ }
910
+ prefixes.add(verifier.prefix);
911
+ }
912
+ const relations = config.relations ?? null;
913
+ if (relations !== null && typeof relations.deleteEntity !== "function") {
914
+ throw new TypeError(`${where}: relations must be a relation store — relations.deleteEntity is missing`);
915
+ }
916
+ const context = createContext(resolved, guardStores(config.store), relations === null ? null : guardRelations(relations), capabilities, config.clock ?? systemClock, hasher, verifiers);
917
+ const shared = sharedApi(context);
918
+ const types = Object.fromEntries([...resolved.types.values()].map((type) => [
919
+ type.name,
920
+ typeApi(context, type)
921
+ ]));
922
+ const surface = resolved.single ? { ...shared, ...types.user } : { ...shared, ...types };
923
+ return surface;
924
+ }
925
+ export {
926
+ CredentialError2 as CredentialError,
927
+ DEFAULT_PAGE_SIZE2 as DEFAULT_PAGE_SIZE,
928
+ InvalidCursorError2 as InvalidCursorError,
929
+ JanusError2 as JanusError,
930
+ MAX_PAGE_SIZE2 as MAX_PAGE_SIZE,
931
+ NotFoundError2 as NotFoundError,
932
+ PermissionDepthError2 as PermissionDepthError,
933
+ StoreConflict2 as StoreConflict,
934
+ StoreFailure2 as StoreFailure,
935
+ TokenError2 as TokenError,
936
+ UnsupportedError2 as UnsupportedError,
937
+ UserInactiveError2 as UserInactiveError,
938
+ UserInvalidError2 as UserInvalidError,
939
+ assertStores,
940
+ bunHasher,
941
+ createMemoryStores2 as createMemoryStores,
942
+ fixedClock,
943
+ formatEntity2 as formatEntity,
944
+ formatSubject2 as formatSubject,
945
+ formatTuple2 as formatTuple,
946
+ invalidCursor2 as invalidCursor,
947
+ isId2 as isId,
948
+ isSubjectSet2 as isSubjectSet,
949
+ janus,
950
+ mintId2 as mintId,
951
+ mintedAt2 as mintedAt,
952
+ pageLimit2 as pageLimit,
953
+ parseDuration,
954
+ parseSubject2 as parseSubject,
955
+ parseTuple2 as parseTuple,
956
+ scryptHasher,
957
+ subjectOf2 as subjectOf,
958
+ systemClock
959
+ };
960
+
961
+ //# debugId=ED5A2746E84B20BA64756E2164756E21
962
+ //# sourceMappingURL=index.js.map