@shaferllc/keel 0.81.0 → 0.81.2

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.
@@ -53,13 +53,31 @@ export function clearModelHooks(cls) {
53
53
  if (cls)
54
54
  registry.delete(cls);
55
55
  }
56
+ /**
57
+ * Every hook for an event that applies to `cls`, including ones registered on its
58
+ * ancestors — ancestors first, so a base class's hook runs before its subclass's.
59
+ *
60
+ * Inheritance is what makes a base class useful. `class Post extends TenantModel`
61
+ * has to fire TenantModel's `creating` hook (the one that stamps the tenant id), or
62
+ * the row is written with no owner and the base class is decorative.
63
+ */
64
+ function hooksFor(cls, event) {
65
+ const chain = [];
66
+ for (let c = cls; c && c !== Function.prototype; c = Object.getPrototypeOf(c)) {
67
+ chain.unshift(c);
68
+ }
69
+ const hooks = [];
70
+ for (const link of chain)
71
+ hooks.push(...(registry.get(link)?.get(event) ?? []));
72
+ return hooks;
73
+ }
56
74
  /**
57
75
  * Fire an event's hooks in registration order. Returns `false` only when a
58
76
  * cancelable event had a hook return `false` — the caller then skips the write.
59
77
  */
60
78
  export async function fireModelEvent(cls, event, model) {
61
- const hooks = registry.get(cls)?.get(event);
62
- if (!hooks?.length)
79
+ const hooks = hooksFor(cls, event);
80
+ if (!hooks.length)
63
81
  return true;
64
82
  const cancelable = CANCELABLE.has(event);
65
83
  for (const hook of hooks) {
@@ -21,7 +21,15 @@ import { type Casts } from "./casts.js";
21
21
  import { type ModelHook, type ModelObserver } from "./model-events.js";
22
22
  import { ModelQuery } from "./model-query.js";
23
23
  /** A global scope: a constraint applied to every query a model builds. */
24
- export type GlobalScope = (query: QueryBuilder) => void;
24
+ /**
25
+ * A global scope: a constraint applied to every query a model builds.
26
+ *
27
+ * The second argument is the model the query is being built for — which a scope
28
+ * declared on a *base* class needs, since it runs for every subclass and they may
29
+ * not all be configured the same way (a tenant scope reading each model's own
30
+ * `teamColumn`, say).
31
+ */
32
+ export type GlobalScope = (query: QueryBuilder, model: typeof Model) => void;
25
33
  /** How a model query treats soft-deleted rows. */
26
34
  type TrashedMode = "exclude" | "with" | "only";
27
35
  type ModelClass<T extends Model> = (new (attributes?: Row) => T) & typeof Model;
@@ -54,9 +62,19 @@ export declare class Model {
54
62
  /** Register a global scope — a constraint applied to every query this model builds. */
55
63
  static addGlobalScope(name: string, scope: GlobalScope): void;
56
64
  /** Build this model's base query, applying global scopes and the soft-delete filter. */
57
- protected static baseQuery(trashed?: TrashedMode): QueryBuilder;
65
+ protected static baseQuery(trashed?: TrashedMode, skip?: Set<string>): QueryBuilder;
58
66
  /** A query builder scoped to this model's table (global scopes applied). */
59
67
  static query(): QueryBuilder;
68
+ /**
69
+ * Drop named global scopes from this query.
70
+ *
71
+ * Deliberately explicit and greppable: a query that escapes a tenancy scope is
72
+ * exactly the thing you want to be able to find at audit time, so it has to be
73
+ * *typed out*, not arrived at by forgetting something.
74
+ */
75
+ static withoutGlobalScope(...names: string[]): QueryBuilder;
76
+ /** Drop every global scope. Same warning, louder. */
77
+ static withoutGlobalScopes(): QueryBuilder;
60
78
  /** Include soft-deleted rows (bypasses the soft-delete scope). */
61
79
  static withTrashed(): QueryBuilder;
62
80
  /** Only soft-deleted rows. */
@@ -21,8 +21,37 @@ import { BelongsTo, BelongsToMany, HasMany, HasOne, MorphMany, MorphOne, MorphTo
21
21
  import { applyCasts, castGet, castSet } from "./casts.js";
22
22
  import { addModelHook, addModelObserver, fireModelEvent, } from "./model-events.js";
23
23
  import { ModelQuery } from "./model-query.js";
24
- /** Registered global scopes, keyed by the model class. */
24
+ /** Registered global scopes, keyed by the model class they were declared on. */
25
25
  const globalScopes = new WeakMap();
26
+ /**
27
+ * Every scope that applies to `cls`, including ones declared on its ancestors.
28
+ *
29
+ * Inheritance is the whole point: a base class exists so its subclasses are
30
+ * constrained by it.
31
+ *
32
+ * class TenantModel extends Model {}
33
+ * TenantModel.addGlobalScope("tenant", (q) => q.where("teamId", currentTeam()));
34
+ * class Post extends TenantModel {} // Post must be scoped too
35
+ *
36
+ * Looking only at the concrete class would leave `Post.query()` completely
37
+ * unconstrained — and a scope that silently does nothing fails *open*, which for
38
+ * a tenancy scope means returning every customer's rows.
39
+ *
40
+ * Walked root-first so a subclass can override an ancestor's scope by reusing its
41
+ * name — the nearest declaration of a given name wins.
42
+ */
43
+ function scopesFor(cls) {
44
+ const chain = [];
45
+ for (let c = cls; c && c !== Function.prototype; c = Object.getPrototypeOf(c)) {
46
+ chain.unshift(c);
47
+ }
48
+ const merged = new Map();
49
+ for (const link of chain) {
50
+ for (const [name, scope] of globalScopes.get(link) ?? [])
51
+ merged.set(name, scope);
52
+ }
53
+ return merged;
54
+ }
26
55
  /**
27
56
  * Loaded relations live off the model itself so they never leak into `save()`
28
57
  * (which spreads own columns) — they're keyed here by the owning instance.
@@ -73,10 +102,13 @@ export class Model {
73
102
  map.set(name, scope);
74
103
  }
75
104
  /** Build this model's base query, applying global scopes and the soft-delete filter. */
76
- static baseQuery(trashed = "exclude") {
105
+ static baseQuery(trashed = "exclude", skip) {
77
106
  const query = db(this.table, this.connection);
78
- for (const scope of globalScopes.get(this)?.values() ?? [])
79
- scope(query);
107
+ for (const [name, scope] of scopesFor(this)) {
108
+ if (skip?.has(name))
109
+ continue;
110
+ scope(query, this);
111
+ }
80
112
  if (this.softDeletes) {
81
113
  if (trashed === "exclude")
82
114
  query.whereNull(this.deletedAtColumn);
@@ -89,6 +121,20 @@ export class Model {
89
121
  static query() {
90
122
  return this.baseQuery();
91
123
  }
124
+ /**
125
+ * Drop named global scopes from this query.
126
+ *
127
+ * Deliberately explicit and greppable: a query that escapes a tenancy scope is
128
+ * exactly the thing you want to be able to find at audit time, so it has to be
129
+ * *typed out*, not arrived at by forgetting something.
130
+ */
131
+ static withoutGlobalScope(...names) {
132
+ return this.baseQuery("exclude", new Set(names));
133
+ }
134
+ /** Drop every global scope. Same warning, louder. */
135
+ static withoutGlobalScopes() {
136
+ return this.baseQuery("exclude", new Set(scopesFor(this).keys()));
137
+ }
92
138
  /** Include soft-deleted rows (bypasses the soft-delete scope). */
93
139
  static withTrashed() {
94
140
  return this.baseQuery("with");
@@ -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
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Putting a request inside a team.
3
+ *
4
+ * `teamContext()` resolves which team the signed-in user is acting as and runs the
5
+ * rest of the request inside it, so every `TenantModel` query is scoped without any
6
+ * handler doing anything.
7
+ *
8
+ * The membership check is the security boundary. `users.current_team_id` is just a
9
+ * number on a row the user can influence, so it is **verified against a membership**
10
+ * on every request — not trusted. Otherwise switching teams is a matter of writing a
11
+ * different id, and tenancy is decoration.
12
+ */
13
+ import type { MiddlewareHandler } from "hono";
14
+ import { type Role } from "./models.js";
15
+ export interface TeamContextOptions {
16
+ /**
17
+ * What to do when a signed-in user has no team at all. `"error"` throws;
18
+ * `"pass"` continues with no team context (so any TenantModel query will throw —
19
+ * which is the right outcome for a route that shouldn't have needed one).
20
+ */
21
+ onMissing?: "error" | "pass";
22
+ }
23
+ /** Resolve the acting team and run the request inside it. */
24
+ export declare function teamContext(options?: TeamContextOptions): MiddlewareHandler;
25
+ /**
26
+ * Require at least this role in the current team.
27
+ *
28
+ * router.delete("/posts/:post", …).middleware(requireRole("admin"))
29
+ */
30
+ export declare function requireRole(role?: Role): MiddlewareHandler;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Putting a request inside a team.
3
+ *
4
+ * `teamContext()` resolves which team the signed-in user is acting as and runs the
5
+ * rest of the request inside it, so every `TenantModel` query is scoped without any
6
+ * handler doing anything.
7
+ *
8
+ * The membership check is the security boundary. `users.current_team_id` is just a
9
+ * number on a row the user can influence, so it is **verified against a membership**
10
+ * on every request — not trusted. Otherwise switching teams is a matter of writing a
11
+ * different id, and tenancy is decoration.
12
+ */
13
+ import { auth } from "../core/auth.js";
14
+ import { db } from "../core/database.js";
15
+ import { ForbiddenException } from "../core/exceptions.js";
16
+ import { runForTeam, withoutTenant } from "./context.js";
17
+ import { Membership, roleOf, teamsFor } from "./models.js";
18
+ import { resolveConfig } from "./config.js";
19
+ /** Resolve the acting team and run the request inside it. */
20
+ export function teamContext(options = {}) {
21
+ return async (c, next) => {
22
+ const userId = auth().id();
23
+ if (!userId) {
24
+ // Nobody's signed in: no team, and no guessing. A tenant-scoped query on this
25
+ // request will throw, which is what we want it to do.
26
+ await next();
27
+ return;
28
+ }
29
+ const teamId = await resolveTeam(userId);
30
+ if (teamId === null) {
31
+ if (options.onMissing === "error") {
32
+ throw new ForbiddenException("You don't belong to any team.");
33
+ }
34
+ await next();
35
+ return;
36
+ }
37
+ c.set("team_id", teamId);
38
+ await runForTeam(teamId, () => next());
39
+ };
40
+ }
41
+ /**
42
+ * Require at least this role in the current team.
43
+ *
44
+ * router.delete("/posts/:post", …).middleware(requireRole("admin"))
45
+ */
46
+ export function requireRole(role = "member") {
47
+ return async (c, next) => {
48
+ const userId = auth().id();
49
+ const teamId = c.get("team_id");
50
+ if (!userId || !teamId)
51
+ throw new ForbiddenException("You don't belong to this team.");
52
+ const actual = await withoutTenant(() => roleOf(userId, teamId));
53
+ if (!actual)
54
+ throw new ForbiddenException("You don't belong to this team.");
55
+ const { roleAtLeast } = await import("./models.js");
56
+ if (!roleAtLeast(actual, role)) {
57
+ throw new ForbiddenException(`This needs the ${role} role.`);
58
+ }
59
+ await next();
60
+ };
61
+ }
62
+ /* --------------------------------- internals ------------------------------ */
63
+ /**
64
+ * The team this user is acting as — verified, not trusted.
65
+ *
66
+ * Reads `current_team_id`, then confirms a membership actually exists. If it doesn't
67
+ * (they were removed, or the id was tampered with), it falls back to a team they are
68
+ * genuinely in rather than honouring the number on the row.
69
+ */
70
+ async function resolveTeam(userId) {
71
+ const settings = resolveConfig();
72
+ // These lookups are *about* teams, so they must not themselves be tenant-scoped.
73
+ return withoutTenant(async () => {
74
+ const user = await db(settings.userTable).where("id", userId).first();
75
+ const current = user?.current_team_id;
76
+ if (current != null) {
77
+ const membership = await db(Membership.table)
78
+ .where("user_id", userId)
79
+ .where("team_id", current)
80
+ .first();
81
+ if (membership)
82
+ return current;
83
+ // Stale or forged: fall through and pick a team they're really in.
84
+ }
85
+ const teams = await teamsFor(userId);
86
+ if (!teams.length)
87
+ return null;
88
+ const first = teams[0].id;
89
+ await db(settings.userTable).where("id", userId).update({ current_team_id: first });
90
+ return first;
91
+ });
92
+ }