@shaferllc/keel 0.80.0 → 0.81.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 (65) hide show
  1. package/dist/accounts/accounts.config.stub +50 -0
  2. package/dist/accounts/config.d.ts +46 -0
  3. package/dist/accounts/config.js +39 -0
  4. package/dist/accounts/flows.d.ts +50 -0
  5. package/dist/accounts/flows.js +133 -0
  6. package/dist/accounts/index.d.ts +28 -0
  7. package/dist/accounts/index.js +23 -0
  8. package/dist/accounts/migration.d.ts +14 -0
  9. package/dist/accounts/migration.js +39 -0
  10. package/dist/accounts/provider.d.ts +18 -0
  11. package/dist/accounts/provider.js +37 -0
  12. package/dist/accounts/routes.d.ts +15 -0
  13. package/dist/accounts/routes.js +116 -0
  14. package/dist/accounts/store.d.ts +33 -0
  15. package/dist/accounts/store.js +37 -0
  16. package/dist/accounts/tokens.d.ts +60 -0
  17. package/dist/accounts/tokens.js +116 -0
  18. package/dist/accounts/totp.d.ts +58 -0
  19. package/dist/accounts/totp.js +134 -0
  20. package/dist/accounts/two-factor.d.ts +56 -0
  21. package/dist/accounts/two-factor.js +146 -0
  22. package/dist/core/database.d.ts +36 -0
  23. package/dist/core/database.js +141 -4
  24. package/dist/core/index.d.ts +5 -2
  25. package/dist/core/index.js +3 -2
  26. package/dist/core/migrations.d.ts +52 -2
  27. package/dist/core/migrations.js +134 -3
  28. package/dist/core/model-events.d.ts +34 -0
  29. package/dist/core/model-events.js +89 -0
  30. package/dist/core/model-query.d.ts +68 -0
  31. package/dist/core/model-query.js +234 -0
  32. package/dist/core/model.d.ts +109 -4
  33. package/dist/core/model.js +263 -32
  34. package/dist/core/relations.d.ts +53 -0
  35. package/dist/core/relations.js +242 -0
  36. package/dist/teams/config.d.ts +27 -0
  37. package/dist/teams/config.js +23 -0
  38. package/dist/teams/context.d.ts +54 -0
  39. package/dist/teams/context.js +73 -0
  40. package/dist/teams/index.d.ts +25 -0
  41. package/dist/teams/index.js +20 -0
  42. package/dist/teams/invitations.d.ts +38 -0
  43. package/dist/teams/invitations.js +123 -0
  44. package/dist/teams/middleware.d.ts +30 -0
  45. package/dist/teams/middleware.js +92 -0
  46. package/dist/teams/migration.d.ts +9 -0
  47. package/dist/teams/migration.js +52 -0
  48. package/dist/teams/models.d.ts +54 -0
  49. package/dist/teams/models.js +85 -0
  50. package/dist/teams/provider.d.ts +17 -0
  51. package/dist/teams/provider.js +27 -0
  52. package/dist/teams/teams.config.stub +24 -0
  53. package/dist/teams/tenant.d.ts +25 -0
  54. package/dist/teams/tenant.js +45 -0
  55. package/docs/accounts.md +214 -0
  56. package/docs/ai-manifest.json +70 -1
  57. package/docs/database.md +80 -0
  58. package/docs/examples/accounts.ts +150 -0
  59. package/docs/examples/teams.ts +101 -0
  60. package/docs/migrations.md +86 -6
  61. package/docs/models.md +279 -6
  62. package/docs/teams.md +176 -0
  63. package/llms-full.txt +849 -12
  64. package/llms.txt +4 -0
  65. package/package.json +10 -2
@@ -28,6 +28,13 @@ import { db } from "./database.js";
28
28
  function unique(values) {
29
29
  return [...new Set(values)];
30
30
  }
31
+ /** Count how many times each value appears — the basis of `withCount`. */
32
+ function tally(values) {
33
+ const counts = new Map();
34
+ for (const value of values)
35
+ counts.set(value, (counts.get(value) ?? 0) + 1);
36
+ return counts;
37
+ }
31
38
  /** Base class: a relationship is awaitable (resolves to its loaded result). */
32
39
  export class Relation {
33
40
  parent;
@@ -76,6 +83,21 @@ export class HasMany extends Relation {
76
83
  m.setRelation(name, grouped.get(m[this.localKey]) ?? []);
77
84
  }
78
85
  }
86
+ parentColumn() {
87
+ return this.localKey;
88
+ }
89
+ async matchingParentKeys(constrain) {
90
+ const q = db(this.related.table, this.related.connection);
91
+ constrain?.(q);
92
+ return unique((await q.pluck(this.foreignKey)).filter((v) => v != null));
93
+ }
94
+ async countsByParent(parentKeys) {
95
+ return tally(parentKeys.length
96
+ ? await db(this.related.table, this.related.connection)
97
+ .whereIn(this.foreignKey, parentKeys)
98
+ .pluck(this.foreignKey)
99
+ : []);
100
+ }
79
101
  }
80
102
  /* --------------------------------- has-one --------------------------------- */
81
103
  export class HasOne extends Relation {
@@ -110,6 +132,21 @@ export class HasOne extends Relation {
110
132
  m.setRelation(name, byKey.get(m[this.localKey]) ?? null);
111
133
  }
112
134
  }
135
+ parentColumn() {
136
+ return this.localKey;
137
+ }
138
+ async matchingParentKeys(constrain) {
139
+ const q = db(this.related.table, this.related.connection);
140
+ constrain?.(q);
141
+ return unique((await q.pluck(this.foreignKey)).filter((v) => v != null));
142
+ }
143
+ async countsByParent(parentKeys) {
144
+ return tally(parentKeys.length
145
+ ? await db(this.related.table, this.related.connection)
146
+ .whereIn(this.foreignKey, parentKeys)
147
+ .pluck(this.foreignKey)
148
+ : []);
149
+ }
113
150
  }
114
151
  /* -------------------------------- belongs-to ------------------------------- */
115
152
  export class BelongsTo extends Relation {
@@ -144,6 +181,25 @@ export class BelongsTo extends Relation {
144
181
  m.setRelation(name, byKey.get(m[this.foreignKey]) ?? null);
145
182
  }
146
183
  }
184
+ parentColumn() {
185
+ return this.foreignKey;
186
+ }
187
+ async matchingParentKeys(constrain) {
188
+ const q = db(this.related.table, this.related.connection);
189
+ constrain?.(q);
190
+ return unique((await q.pluck(this.ownerKey)).filter((v) => v != null));
191
+ }
192
+ async countsByParent(parentKeys) {
193
+ const existing = new Set(parentKeys.length
194
+ ? await db(this.related.table, this.related.connection)
195
+ .whereIn(this.ownerKey, parentKeys)
196
+ .pluck(this.ownerKey)
197
+ : []);
198
+ const counts = new Map();
199
+ for (const key of parentKeys)
200
+ counts.set(key, existing.has(key) ? 1 : 0);
201
+ return counts;
202
+ }
147
203
  }
148
204
  /* ----------------------------- belongs-to-many ----------------------------- */
149
205
  export class BelongsToMany extends Relation {
@@ -226,4 +282,190 @@ export class BelongsToMany extends Relation {
226
282
  for (const id of ids)
227
283
  await this.attach(id);
228
284
  }
285
+ parentColumn() {
286
+ return this.parentKey;
287
+ }
288
+ async matchingParentKeys(constrain) {
289
+ const rq = db(this.related.table, this.related.connection);
290
+ constrain?.(rq);
291
+ const relatedIds = unique((await rq.pluck(this.relatedKey)).filter((v) => v != null));
292
+ if (!relatedIds.length)
293
+ return [];
294
+ const pivots = await db(this.pivotTable, this.related.connection)
295
+ .whereIn(this.relatedPivotKey, relatedIds)
296
+ .pluck(this.foreignPivotKey);
297
+ return unique(pivots.filter((v) => v != null));
298
+ }
299
+ async countsByParent(parentKeys) {
300
+ return tally(parentKeys.length
301
+ ? await db(this.pivotTable, this.related.connection)
302
+ .whereIn(this.foreignPivotKey, parentKeys)
303
+ .pluck(this.foreignPivotKey)
304
+ : []);
305
+ }
306
+ }
307
+ /* ----------------------------- polymorphic --------------------------------- */
308
+ /** Maps a stored morph-type string to its model class, so `morphTo` can resolve it. */
309
+ const morphRegistry = new Map();
310
+ /** Register a model under a morph type string (usually its class name). */
311
+ export function registerMorphType(type, related) {
312
+ morphRegistry.set(type, related);
313
+ }
314
+ /** The parent side of a polymorphic one-to-many (`Post.comments()` over `commentable`). */
315
+ export class MorphMany extends Relation {
316
+ morphType;
317
+ idColumn;
318
+ typeColumn;
319
+ localKey;
320
+ constructor(parent, related, morphType, idColumn, typeColumn, localKey) {
321
+ super(parent, related);
322
+ this.morphType = morphType;
323
+ this.idColumn = idColumn;
324
+ this.typeColumn = typeColumn;
325
+ this.localKey = localKey;
326
+ }
327
+ localValue() {
328
+ return this.parent[this.localKey];
329
+ }
330
+ query() {
331
+ return db(this.related.table, this.related.connection)
332
+ .where(this.typeColumn, this.morphType)
333
+ .where(this.idColumn, this.localValue());
334
+ }
335
+ async get() {
336
+ return this.hydrate(await this.query().get());
337
+ }
338
+ async eager(models, name) {
339
+ const keys = unique(models.map((m) => m[this.localKey]).filter((v) => v != null));
340
+ const rows = keys.length
341
+ ? await db(this.related.table, this.related.connection)
342
+ .where(this.typeColumn, this.morphType)
343
+ .whereIn(this.idColumn, keys)
344
+ .get()
345
+ : [];
346
+ const grouped = new Map();
347
+ for (const row of rows) {
348
+ const bucket = grouped.get(row[this.idColumn]) ?? [];
349
+ bucket.push(new this.related(row));
350
+ grouped.set(row[this.idColumn], bucket);
351
+ }
352
+ for (const m of models)
353
+ m.setRelation(name, grouped.get(m[this.localKey]) ?? []);
354
+ }
355
+ parentColumn() {
356
+ return this.localKey;
357
+ }
358
+ async matchingParentKeys(constrain) {
359
+ const q = db(this.related.table, this.related.connection).where(this.typeColumn, this.morphType);
360
+ constrain?.(q);
361
+ return unique((await q.pluck(this.idColumn)).filter((v) => v != null));
362
+ }
363
+ async countsByParent(parentKeys) {
364
+ return tally(parentKeys.length
365
+ ? await db(this.related.table, this.related.connection)
366
+ .where(this.typeColumn, this.morphType)
367
+ .whereIn(this.idColumn, parentKeys)
368
+ .pluck(this.idColumn)
369
+ : []);
370
+ }
371
+ /** Create a related row with the morph keys (`*_id` / `*_type`) filled in. */
372
+ create(attributes) {
373
+ return this.related.create({
374
+ ...attributes,
375
+ [this.idColumn]: this.localValue(),
376
+ [this.typeColumn]: this.morphType,
377
+ });
378
+ }
379
+ }
380
+ /** The parent side of a polymorphic one-to-one. */
381
+ export class MorphOne extends Relation {
382
+ morphType;
383
+ idColumn;
384
+ typeColumn;
385
+ localKey;
386
+ constructor(parent, related, morphType, idColumn, typeColumn, localKey) {
387
+ super(parent, related);
388
+ this.morphType = morphType;
389
+ this.idColumn = idColumn;
390
+ this.typeColumn = typeColumn;
391
+ this.localKey = localKey;
392
+ }
393
+ query() {
394
+ return db(this.related.table, this.related.connection)
395
+ .where(this.typeColumn, this.morphType)
396
+ .where(this.idColumn, this.parent[this.localKey]);
397
+ }
398
+ async get() {
399
+ const row = await this.query().first();
400
+ return row ? new this.related(row) : null;
401
+ }
402
+ async eager(models, name) {
403
+ const keys = unique(models.map((m) => m[this.localKey]).filter((v) => v != null));
404
+ const rows = keys.length
405
+ ? await db(this.related.table, this.related.connection)
406
+ .where(this.typeColumn, this.morphType)
407
+ .whereIn(this.idColumn, keys)
408
+ .get()
409
+ : [];
410
+ const byKey = new Map();
411
+ for (const row of rows)
412
+ if (!byKey.has(row[this.idColumn]))
413
+ byKey.set(row[this.idColumn], new this.related(row));
414
+ for (const m of models)
415
+ m.setRelation(name, byKey.get(m[this.localKey]) ?? null);
416
+ }
417
+ }
418
+ /** The owning side of a polymorphic relation — resolves its parent by stored type + id. */
419
+ export class MorphTo {
420
+ parent;
421
+ idColumn;
422
+ typeColumn;
423
+ constructor(parent, idColumn, typeColumn) {
424
+ this.parent = parent;
425
+ this.idColumn = idColumn;
426
+ this.typeColumn = typeColumn;
427
+ }
428
+ relatedClass() {
429
+ const type = this.parent[this.typeColumn];
430
+ return type ? morphRegistry.get(type) : undefined;
431
+ }
432
+ async get() {
433
+ const cls = this.relatedClass();
434
+ const id = this.parent[this.idColumn];
435
+ if (!cls || id == null)
436
+ return null;
437
+ const row = await db(cls.table, cls.connection).where(cls.primaryKey, id).first();
438
+ return row ? new cls(row) : null;
439
+ }
440
+ async eager(models, name) {
441
+ const byType = new Map();
442
+ for (const m of models) {
443
+ const type = m[this.typeColumn];
444
+ if (!type) {
445
+ m.setRelation(name, null);
446
+ continue;
447
+ }
448
+ const group = byType.get(type) ?? [];
449
+ group.push(m);
450
+ byType.set(type, group);
451
+ }
452
+ for (const [type, group] of byType) {
453
+ const cls = morphRegistry.get(type);
454
+ if (!cls) {
455
+ for (const m of group)
456
+ m.setRelation(name, null);
457
+ continue;
458
+ }
459
+ const ids = unique(group.map((m) => m[this.idColumn]).filter((v) => v != null));
460
+ const rows = ids.length ? await db(cls.table, cls.connection).whereIn(cls.primaryKey, ids).get() : [];
461
+ const byId = new Map(rows.map((row) => [row[cls.primaryKey], row]));
462
+ for (const m of group) {
463
+ const row = byId.get(m[this.idColumn]);
464
+ m.setRelation(name, row ? new cls(row) : null);
465
+ }
466
+ }
467
+ }
468
+ then(onFulfilled, onRejected) {
469
+ return this.get().then(onFulfilled, onRejected);
470
+ }
229
471
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Teams configuration. Merged under `config("teams")` by the provider; override in
3
+ * `config/teams.ts` (publish it with `keel vendor:publish --tag teams-config`).
4
+ */
5
+ export interface TeamsConfig {
6
+ /** The users table. Teams adds `current_team_id` to it and touches nothing else. */
7
+ userTable: string;
8
+ /**
9
+ * Give every new user a team of their own on signup.
10
+ *
11
+ * On by default, and worth leaving on even for an app that feels single-user:
12
+ * a "personal workspace" is just a team of one, and *adding* tenancy later means
13
+ * backfilling every table and rewriting every query. Ignoring a team you have is
14
+ * one unused row; needing a team you don't have is a migration nobody enjoys.
15
+ */
16
+ personalTeams: boolean;
17
+ invitations: {
18
+ expiresInHours: number;
19
+ /** Where the emailed link points. `:token` is replaced. */
20
+ url: string;
21
+ };
22
+ mail: {
23
+ from?: string;
24
+ };
25
+ }
26
+ export declare const defaultConfig: TeamsConfig;
27
+ export declare function resolveConfig(): TeamsConfig;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Teams configuration. Merged under `config("teams")` by the provider; override in
3
+ * `config/teams.ts` (publish it with `keel vendor:publish --tag teams-config`).
4
+ */
5
+ import { config } from "../core/helpers.js";
6
+ export const defaultConfig = {
7
+ userTable: "users",
8
+ personalTeams: true,
9
+ invitations: {
10
+ expiresInHours: 72,
11
+ url: "/invitations/:token",
12
+ },
13
+ mail: {},
14
+ };
15
+ export function resolveConfig() {
16
+ const raw = config("teams", {});
17
+ return {
18
+ userTable: raw.userTable ?? defaultConfig.userTable,
19
+ personalTeams: raw.personalTeams ?? defaultConfig.personalTeams,
20
+ invitations: { ...defaultConfig.invitations, ...raw.invitations },
21
+ mail: { ...defaultConfig.mail, ...raw.mail },
22
+ };
23
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Which team the current work belongs to.
3
+ *
4
+ * Carried in `AsyncLocalStorage`, not a module global, so two concurrent requests
5
+ * can't see each other's team — the same reason Keel carries the request and the
6
+ * open transaction that way.
7
+ *
8
+ * **There is no "no team" fallback.** `currentTeamId()` throws when nothing has set
9
+ * a team, and `TenantModel`'s scope calls it on every query. That is deliberate, and
10
+ * it is the whole security model:
11
+ *
12
+ * - Return *unscoped* when there's no team, and every background job silently sees
13
+ * every tenant's rows. This is how customer A's invoice reaches customer B.
14
+ * - Return `undefined` into the where clause (`teamId = NULL`) and jobs match
15
+ * nothing, "work" fine, and quietly do nothing for a month.
16
+ * - Throw, and a job that forgot crashes in development instead of leaking in
17
+ * production.
18
+ *
19
+ * A job, a console command, a webhook, a seeder — none of them run inside a request,
20
+ * so each one has to say which team it is for:
21
+ *
22
+ * await runForTeam(team, () => sendInvoices());
23
+ *
24
+ * ...or say, out loud and greppably, that it isn't for one:
25
+ *
26
+ * await withoutTenant(() => Post.query().get()); // every team's posts
27
+ */
28
+ export interface TeamContext {
29
+ /** `null` means "deliberately no tenant" — set by `withoutTenant`. */
30
+ teamId: string | number | null;
31
+ }
32
+ /** Run `fn` with this team as the current tenant. */
33
+ export declare function runForTeam<T>(team: string | number | {
34
+ id: string | number;
35
+ }, fn: () => T): T;
36
+ /**
37
+ * Run `fn` with tenant scoping switched **off**.
38
+ *
39
+ * Every use of this is a query that crosses tenant boundaries, so it should be easy
40
+ * to find and easy to justify. That's the point of making it a named call rather
41
+ * than something you get by forgetting a `where`.
42
+ */
43
+ export declare function withoutTenant<T>(fn: () => T): T;
44
+ /** The current team's id, or `undefined` outside any team context. */
45
+ export declare function currentTeam(): string | number | null | undefined;
46
+ /** Is there a team context at all (including a deliberate `withoutTenant`)? */
47
+ export declare function hasTeamContext(): boolean;
48
+ /**
49
+ * The current team's id — or an error.
50
+ *
51
+ * Called by the tenant scope on every query, so this is the thing that turns "I
52
+ * forgot" into a crash rather than a leak.
53
+ */
54
+ export declare function currentTeamId(): string | number;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Which team the current work belongs to.
3
+ *
4
+ * Carried in `AsyncLocalStorage`, not a module global, so two concurrent requests
5
+ * can't see each other's team — the same reason Keel carries the request and the
6
+ * open transaction that way.
7
+ *
8
+ * **There is no "no team" fallback.** `currentTeamId()` throws when nothing has set
9
+ * a team, and `TenantModel`'s scope calls it on every query. That is deliberate, and
10
+ * it is the whole security model:
11
+ *
12
+ * - Return *unscoped* when there's no team, and every background job silently sees
13
+ * every tenant's rows. This is how customer A's invoice reaches customer B.
14
+ * - Return `undefined` into the where clause (`teamId = NULL`) and jobs match
15
+ * nothing, "work" fine, and quietly do nothing for a month.
16
+ * - Throw, and a job that forgot crashes in development instead of leaking in
17
+ * production.
18
+ *
19
+ * A job, a console command, a webhook, a seeder — none of them run inside a request,
20
+ * so each one has to say which team it is for:
21
+ *
22
+ * await runForTeam(team, () => sendInvoices());
23
+ *
24
+ * ...or say, out loud and greppably, that it isn't for one:
25
+ *
26
+ * await withoutTenant(() => Post.query().get()); // every team's posts
27
+ */
28
+ import { AsyncLocalStorage } from "node:async_hooks";
29
+ const storage = new AsyncLocalStorage();
30
+ /** Run `fn` with this team as the current tenant. */
31
+ export function runForTeam(team, fn) {
32
+ const teamId = typeof team === "object" ? team.id : team;
33
+ return storage.run({ teamId }, fn);
34
+ }
35
+ /**
36
+ * Run `fn` with tenant scoping switched **off**.
37
+ *
38
+ * Every use of this is a query that crosses tenant boundaries, so it should be easy
39
+ * to find and easy to justify. That's the point of making it a named call rather
40
+ * than something you get by forgetting a `where`.
41
+ */
42
+ export function withoutTenant(fn) {
43
+ return storage.run({ teamId: null }, fn);
44
+ }
45
+ /** The current team's id, or `undefined` outside any team context. */
46
+ export function currentTeam() {
47
+ return storage.getStore()?.teamId;
48
+ }
49
+ /** Is there a team context at all (including a deliberate `withoutTenant`)? */
50
+ export function hasTeamContext() {
51
+ return storage.getStore() !== undefined;
52
+ }
53
+ /**
54
+ * The current team's id — or an error.
55
+ *
56
+ * Called by the tenant scope on every query, so this is the thing that turns "I
57
+ * forgot" into a crash rather than a leak.
58
+ */
59
+ export function currentTeamId() {
60
+ const store = storage.getStore();
61
+ if (!store) {
62
+ throw new Error("No team in context, so a tenant-scoped query can't be built safely.\n" +
63
+ "Inside a request, add teamContext() to your middleware.\n" +
64
+ "In a job, command, or seeder, wrap the work: runForTeam(team, () => …).\n" +
65
+ "If it genuinely spans every team, say so: withoutTenant(() => …).");
66
+ }
67
+ if (store.teamId === null) {
68
+ // withoutTenant() — the caller has already said this query crosses tenants, and
69
+ // the scope is skipped before it ever asks for an id.
70
+ throw new Error("currentTeamId() called inside withoutTenant().");
71
+ }
72
+ return store.teamId;
73
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Keel Teams — multi-tenancy, membership, roles, invitations.
3
+ * Imported from `@shaferllc/keel/teams`.
4
+ *
5
+ * class Post extends TenantModel { static table = "posts"; }
6
+ * await Post.all(); // only the current team's posts. Always.
7
+ *
8
+ * Isolation is deny-by-default: a `TenantModel` query outside a team context
9
+ * **throws** rather than quietly returning everything. Crossing tenants is possible,
10
+ * but only by saying so — `withoutTenant(() => …)` — which is a thing you can grep
11
+ * for at audit time. See context.ts for why the alternatives are worse.
12
+ */
13
+ export { TeamsServiceProvider } from "./provider.js";
14
+ export { TenantModel, TENANT_SCOPE } from "./tenant.js";
15
+ export { currentTeam, currentTeamId, hasTeamContext, runForTeam, withoutTenant, } from "./context.js";
16
+ export type { TeamContext } from "./context.js";
17
+ export { Team, Membership, ROLES, createTeam, memberOf, roleAtLeast, roleOf, switchTeam, teamsFor, } from "./models.js";
18
+ export type { Role } from "./models.js";
19
+ export { acceptInvitation, invite, pendingInvitations, revokeInvitation, } from "./invitations.js";
20
+ export type { Invitation, SentInvitation } from "./invitations.js";
21
+ export { requireRole, teamContext } from "./middleware.js";
22
+ export type { TeamContextOptions } from "./middleware.js";
23
+ export { teamsMigration } from "./migration.js";
24
+ export { defaultConfig, resolveConfig } from "./config.js";
25
+ export type { TeamsConfig } from "./config.js";
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Keel Teams — multi-tenancy, membership, roles, invitations.
3
+ * Imported from `@shaferllc/keel/teams`.
4
+ *
5
+ * class Post extends TenantModel { static table = "posts"; }
6
+ * await Post.all(); // only the current team's posts. Always.
7
+ *
8
+ * Isolation is deny-by-default: a `TenantModel` query outside a team context
9
+ * **throws** rather than quietly returning everything. Crossing tenants is possible,
10
+ * but only by saying so — `withoutTenant(() => …)` — which is a thing you can grep
11
+ * for at audit time. See context.ts for why the alternatives are worse.
12
+ */
13
+ export { TeamsServiceProvider } from "./provider.js";
14
+ export { TenantModel, TENANT_SCOPE } from "./tenant.js";
15
+ export { currentTeam, currentTeamId, hasTeamContext, runForTeam, withoutTenant, } from "./context.js";
16
+ export { Team, Membership, ROLES, createTeam, memberOf, roleAtLeast, roleOf, switchTeam, teamsFor, } from "./models.js";
17
+ export { acceptInvitation, invite, pendingInvitations, revokeInvitation, } from "./invitations.js";
18
+ export { requireRole, teamContext } from "./middleware.js";
19
+ export { teamsMigration } from "./migration.js";
20
+ export { defaultConfig, resolveConfig } from "./config.js";
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Team invitations.
3
+ *
4
+ * Unlike password reset, an invitation **is** a database row — because it has to be
5
+ * listable ("3 pending invites") and revocable, and you can't revoke a stateless
6
+ * token. What isn't stored is the token itself: only its hash, so a leaked database
7
+ * doesn't let someone walk into every pending team.
8
+ *
9
+ * The email is baked into the invitation and re-checked on accept, so a forwarded
10
+ * link doesn't let a different person join in the invitee's place.
11
+ */
12
+ import { Team, type Role } from "./models.js";
13
+ export interface Invitation {
14
+ id: number;
15
+ team_id: number;
16
+ email: string;
17
+ role: Role;
18
+ expires_at: string;
19
+ }
20
+ export interface SentInvitation {
21
+ invitation: Invitation;
22
+ /** The plaintext token — it goes in the email and is never stored. */
23
+ token: string;
24
+ }
25
+ /** Invite someone to a team. Returns the token so a caller can build its own link. */
26
+ export declare function invite(teamId: string | number, email: string, role?: Role): Promise<SentInvitation>;
27
+ /** Outstanding invitations for a team. */
28
+ export declare function pendingInvitations(teamId: string | number): Promise<Invitation[]>;
29
+ /** Withdraw an invitation. */
30
+ export declare function revokeInvitation(id: string | number): Promise<void>;
31
+ /**
32
+ * Accept an invitation, joining the team.
33
+ *
34
+ * `email` is the address of the person actually accepting — it must match the one
35
+ * invited. Otherwise a forwarded link lets anyone join a team they were never asked
36
+ * to, which is the interesting attack on an invitation system.
37
+ */
38
+ export declare function acceptInvitation(token: string, userId: string | number, email: string): Promise<Team | null>;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Team invitations.
3
+ *
4
+ * Unlike password reset, an invitation **is** a database row — because it has to be
5
+ * listable ("3 pending invites") and revocable, and you can't revoke a stateless
6
+ * token. What isn't stored is the token itself: only its hash, so a leaked database
7
+ * doesn't let someone walk into every pending team.
8
+ *
9
+ * The email is baked into the invitation and re-checked on accept, so a forwarded
10
+ * link doesn't let a different person join in the invitee's place.
11
+ */
12
+ import { db } from "../core/database.js";
13
+ import { hash } from "../core/crypto.js";
14
+ import { config } from "../core/helpers.js";
15
+ import { mail } from "../core/mail.js";
16
+ import { Membership, Team } from "./models.js";
17
+ import { resolveConfig } from "./config.js";
18
+ /** Invite someone to a team. Returns the token so a caller can build its own link. */
19
+ export async function invite(teamId, email, role = "member") {
20
+ const settings = resolveConfig();
21
+ const address = email.toLowerCase();
22
+ // Re-inviting replaces the outstanding invitation rather than stacking duplicates.
23
+ await db("team_invitations").where("team_id", teamId).where("email", address).delete();
24
+ const token = randomToken();
25
+ const expires = new Date(Date.now() + settings.invitations.expiresInHours * 3_600_000);
26
+ const id = await db("team_invitations").insertGetId({
27
+ team_id: teamId,
28
+ email: address,
29
+ role,
30
+ // Only the hash. The token exists in the email and nowhere else.
31
+ token: await hash.make(token),
32
+ expires_at: expires.toISOString(),
33
+ created_at: new Date().toISOString(),
34
+ });
35
+ const invitation = {
36
+ id: Number(id),
37
+ team_id: Number(teamId),
38
+ email: address,
39
+ role,
40
+ expires_at: expires.toISOString(),
41
+ };
42
+ await sendInvitationEmail(invitation, token);
43
+ return { invitation, token };
44
+ }
45
+ /** Outstanding invitations for a team. */
46
+ export async function pendingInvitations(teamId) {
47
+ const rows = await db("team_invitations").where("team_id", teamId).get();
48
+ return rows.map(toInvitation);
49
+ }
50
+ /** Withdraw an invitation. */
51
+ export async function revokeInvitation(id) {
52
+ await db("team_invitations").where("id", id).delete();
53
+ }
54
+ /**
55
+ * Accept an invitation, joining the team.
56
+ *
57
+ * `email` is the address of the person actually accepting — it must match the one
58
+ * invited. Otherwise a forwarded link lets anyone join a team they were never asked
59
+ * to, which is the interesting attack on an invitation system.
60
+ */
61
+ export async function acceptInvitation(token, userId, email) {
62
+ const address = email.toLowerCase();
63
+ const rows = await db("team_invitations").where("email", address).get();
64
+ for (const row of rows) {
65
+ if (!(await hash.verify(String(row.token), token)))
66
+ continue;
67
+ // Expired invitations are dead, and worth clearing out while we're here.
68
+ if (new Date(String(row.expires_at)) < new Date()) {
69
+ await db("team_invitations").where("id", row.id).delete();
70
+ return null;
71
+ }
72
+ const existing = await db(Membership.table)
73
+ .where("team_id", row.team_id)
74
+ .where("user_id", userId)
75
+ .first();
76
+ if (!existing) {
77
+ await Membership.create({
78
+ team_id: row.team_id,
79
+ user_id: userId,
80
+ role: row.role,
81
+ });
82
+ }
83
+ // Single use.
84
+ await db("team_invitations").where("id", row.id).delete();
85
+ const team = await db(Team.table).where("id", row.team_id).first();
86
+ return team ? new Team(team) : null;
87
+ }
88
+ return null;
89
+ }
90
+ /* --------------------------------- internals ------------------------------ */
91
+ async function sendInvitationEmail(invitation, token) {
92
+ const settings = resolveConfig();
93
+ const team = await db(Team.table).where("id", invitation.team_id).first();
94
+ const link = absolute(settings.invitations.url.replace(":token", encodeURIComponent(token)));
95
+ const message = mail()
96
+ .to(invitation.email)
97
+ .subject(`You've been invited to ${team?.name ?? "a team"}`)
98
+ .html(`<p>You've been invited to join <strong>${team?.name ?? "a team"}</strong>.</p>` +
99
+ `<p><a href="${link}">Accept the invitation</a></p>` +
100
+ `<p>It expires in ${settings.invitations.expiresInHours} hours.</p>`);
101
+ if (settings.mail.from)
102
+ message.from(settings.mail.from);
103
+ await message.send();
104
+ }
105
+ function randomToken() {
106
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
107
+ return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
108
+ }
109
+ function toInvitation(row) {
110
+ return {
111
+ id: Number(row.id),
112
+ team_id: Number(row.team_id),
113
+ email: String(row.email),
114
+ role: row.role,
115
+ expires_at: String(row.expires_at),
116
+ };
117
+ }
118
+ function absolute(url) {
119
+ if (/^https?:\/\//i.test(url))
120
+ return url;
121
+ const base = config("app.url", "http://localhost:3000").replace(/\/$/, "");
122
+ return `${base}${url.startsWith("/") ? "" : "/"}${url}`;
123
+ }