@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.
- package/dist/core/model-events.js +20 -2
- package/dist/core/model.d.ts +20 -2
- package/dist/core/model.js +50 -4
- package/dist/teams/config.d.ts +27 -0
- package/dist/teams/config.js +23 -0
- package/dist/teams/context.d.ts +54 -0
- package/dist/teams/context.js +73 -0
- package/dist/teams/index.d.ts +25 -0
- package/dist/teams/index.js +20 -0
- package/dist/teams/invitations.d.ts +38 -0
- package/dist/teams/invitations.js +123 -0
- package/dist/teams/middleware.d.ts +30 -0
- package/dist/teams/middleware.js +92 -0
- package/dist/teams/migration.d.ts +9 -0
- package/dist/teams/migration.js +52 -0
- package/dist/teams/models.d.ts +54 -0
- package/dist/teams/models.js +85 -0
- package/dist/teams/provider.d.ts +17 -0
- package/dist/teams/provider.js +27 -0
- package/dist/teams/teams.config.stub +24 -0
- package/dist/teams/tenant.d.ts +25 -0
- package/dist/teams/tenant.js +45 -0
- package/docs/ai-manifest.json +15 -1
- package/docs/changelog.md +2095 -0
- package/docs/database.md +47 -0
- package/docs/examples/teams.ts +101 -0
- package/docs/migrations.md +54 -3
- package/docs/models.md +146 -3
- package/docs/teams.md +176 -0
- package/llms-full.txt +430 -6
- package/llms.txt +3 -0
- package/package.json +6 -2
|
@@ -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 =
|
|
62
|
-
if (!hooks
|
|
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) {
|
package/dist/core/model.d.ts
CHANGED
|
@@ -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
|
-
|
|
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. */
|
package/dist/core/model.js
CHANGED
|
@@ -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
|
|
79
|
-
|
|
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
|
+
}
|