@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
@@ -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
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The teams schema: teams, memberships, invitations, and one column on users.
3
+ *
4
+ * `team_memberships` is what actually grants access — `teams.owner_id` is a
5
+ * convenience, not an authorization source. A user is in a team if and only if a
6
+ * membership row says so.
7
+ */
8
+ import type { Migration } from "../core/migrations.js";
9
+ export declare function teamsMigration(userTable?: string): Migration;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The teams schema: teams, memberships, invitations, and one column on users.
3
+ *
4
+ * `team_memberships` is what actually grants access — `teams.owner_id` is a
5
+ * convenience, not an authorization source. A user is in a team if and only if a
6
+ * membership row says so.
7
+ */
8
+ export function teamsMigration(userTable = "users") {
9
+ return {
10
+ name: "teams_00_create_teams",
11
+ async up(schema) {
12
+ await schema.createTable("teams", (t) => {
13
+ t.id();
14
+ t.string("name");
15
+ t.string("slug").unique();
16
+ t.integer("owner_id");
17
+ t.timestamps();
18
+ });
19
+ await schema.createTable("team_memberships", (t) => {
20
+ t.id();
21
+ t.integer("team_id");
22
+ t.integer("user_id");
23
+ // owner | admin | member
24
+ t.string("role", 32).default("member");
25
+ t.timestamps();
26
+ });
27
+ await schema.createTable("team_invitations", (t) => {
28
+ t.id();
29
+ t.integer("team_id");
30
+ t.string("email");
31
+ t.string("role", 32).default("member");
32
+ // The token's HASH. The token itself lives in the invitee's inbox and
33
+ // nowhere else, so a database leak doesn't open every pending team.
34
+ t.string("token");
35
+ t.timestamp("expires_at");
36
+ t.timestamps();
37
+ });
38
+ // One membership per user per team, enforced by the database rather than by
39
+ // remembering to check — accepting an invitation twice must not double up.
40
+ await schema.raw("CREATE UNIQUE INDEX IF NOT EXISTS team_memberships_unique ON team_memberships (team_id, user_id)");
41
+ await schema.raw("CREATE INDEX IF NOT EXISTS team_invitations_email ON team_invitations (email)");
42
+ // Which team the user is currently looking at.
43
+ await schema.raw(`ALTER TABLE ${userTable} ADD COLUMN current_team_id INTEGER`);
44
+ },
45
+ async down(schema) {
46
+ await schema.dropTable("team_invitations");
47
+ await schema.dropTable("team_memberships");
48
+ await schema.dropTable("teams");
49
+ await schema.raw(`ALTER TABLE ${userTable} DROP COLUMN current_team_id`).catch(() => { });
50
+ },
51
+ };
52
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Teams and membership.
3
+ *
4
+ * These are plain models, deliberately **not** `TenantModel` — the tenant scope
5
+ * constrains queries to the current team, and you have to be able to ask "which
6
+ * teams does this user belong to?" *before* you know which team you're in.
7
+ */
8
+ import { Model } from "../core/model.js";
9
+ /** Who can do what. Ordered: an owner can do anything an admin can, and so on. */
10
+ export declare const ROLES: readonly ["owner", "admin", "member"];
11
+ export type Role = (typeof ROLES)[number];
12
+ /** Does `role` carry at least the authority of `needed`? */
13
+ export declare function roleAtLeast(role: Role, needed: Role): boolean;
14
+ export declare class Team extends Model {
15
+ static table: string;
16
+ static fillable: string[];
17
+ static timestamps: boolean;
18
+ id: number;
19
+ name: string;
20
+ slug: string;
21
+ owner_id: number;
22
+ /** Everyone in this team, with their role. */
23
+ members(): Promise<Membership[]>;
24
+ }
25
+ export declare class Membership extends Model {
26
+ static table: string;
27
+ static fillable: string[];
28
+ static timestamps: boolean;
29
+ id: number;
30
+ team_id: number;
31
+ user_id: number;
32
+ role: Role;
33
+ }
34
+ /** The teams a user belongs to. */
35
+ export declare function teamsFor(userId: string | number): Promise<Team[]>;
36
+ /** A user's role in a team, or `null` if they aren't in it. */
37
+ export declare function roleOf(userId: string | number, teamId: string | number): Promise<Role | null>;
38
+ /** Is this user in this team, at least at this level? */
39
+ export declare function memberOf(userId: string | number, teamId: string | number, atLeast?: Role): Promise<boolean>;
40
+ /**
41
+ * Create a team and make its creator the owner.
42
+ *
43
+ * The membership row is what actually grants access — `teams.owner_id` alone would
44
+ * leave the owner out of every membership query.
45
+ */
46
+ export declare function createTeam(name: string, ownerId: string | number, slug?: string): Promise<Team>;
47
+ /**
48
+ * Switch which team a user is acting as — **only** to a team they're actually in.
49
+ *
50
+ * The check is the point. `current_team_id` is a number on the user's own row; if
51
+ * switching didn't verify membership, changing teams would just be a matter of
52
+ * writing someone else's id into it.
53
+ */
54
+ export declare function switchTeam(userId: string | number, teamId: string | number, userTable?: string): Promise<boolean>;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Teams and membership.
3
+ *
4
+ * These are plain models, deliberately **not** `TenantModel` — the tenant scope
5
+ * constrains queries to the current team, and you have to be able to ask "which
6
+ * teams does this user belong to?" *before* you know which team you're in.
7
+ */
8
+ import { Model } from "../core/model.js";
9
+ import { db } from "../core/database.js";
10
+ /** Who can do what. Ordered: an owner can do anything an admin can, and so on. */
11
+ export const ROLES = ["owner", "admin", "member"];
12
+ const RANK = { owner: 3, admin: 2, member: 1 };
13
+ /** Does `role` carry at least the authority of `needed`? */
14
+ export function roleAtLeast(role, needed) {
15
+ return RANK[role] >= RANK[needed];
16
+ }
17
+ export class Team extends Model {
18
+ static table = "teams";
19
+ static fillable = ["name", "slug", "owner_id"];
20
+ static timestamps = true;
21
+ /** Everyone in this team, with their role. */
22
+ async members() {
23
+ const rows = await db(Membership.table).where("team_id", this.id).get();
24
+ return rows.map((row) => new Membership(row));
25
+ }
26
+ }
27
+ export class Membership extends Model {
28
+ static table = "team_memberships";
29
+ static fillable = ["team_id", "user_id", "role"];
30
+ static timestamps = true;
31
+ }
32
+ /* --------------------------------- lookups -------------------------------- */
33
+ /** The teams a user belongs to. */
34
+ export async function teamsFor(userId) {
35
+ const memberships = await db(Membership.table).where("user_id", userId).get();
36
+ if (!memberships.length)
37
+ return [];
38
+ const ids = memberships.map((m) => m.team_id);
39
+ const rows = await db(Team.table).whereIn("id", ids).get();
40
+ return rows.map((row) => new Team(row));
41
+ }
42
+ /** A user's role in a team, or `null` if they aren't in it. */
43
+ export async function roleOf(userId, teamId) {
44
+ const row = await db(Membership.table).where("user_id", userId).where("team_id", teamId).first();
45
+ return row ? row.role : null;
46
+ }
47
+ /** Is this user in this team, at least at this level? */
48
+ export async function memberOf(userId, teamId, atLeast = "member") {
49
+ const role = await roleOf(userId, teamId);
50
+ return role ? roleAtLeast(role, atLeast) : false;
51
+ }
52
+ /**
53
+ * Create a team and make its creator the owner.
54
+ *
55
+ * The membership row is what actually grants access — `teams.owner_id` alone would
56
+ * leave the owner out of every membership query.
57
+ */
58
+ export async function createTeam(name, ownerId, slug) {
59
+ const team = await Team.create({
60
+ name,
61
+ slug: slug ?? slugify(name),
62
+ owner_id: ownerId,
63
+ });
64
+ await Membership.create({ team_id: team.id, user_id: ownerId, role: "owner" });
65
+ return team;
66
+ }
67
+ /**
68
+ * Switch which team a user is acting as — **only** to a team they're actually in.
69
+ *
70
+ * The check is the point. `current_team_id` is a number on the user's own row; if
71
+ * switching didn't verify membership, changing teams would just be a matter of
72
+ * writing someone else's id into it.
73
+ */
74
+ export async function switchTeam(userId, teamId, userTable = "users") {
75
+ if (!(await memberOf(userId, teamId)))
76
+ return false;
77
+ await db(userTable).where("id", userId).update({ current_team_id: teamId });
78
+ return true;
79
+ }
80
+ function slugify(name) {
81
+ return name
82
+ .toLowerCase()
83
+ .replace(/[^a-z0-9]+/g, "-")
84
+ .replace(/^-|-$/g, "");
85
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Teams — multi-tenancy, membership, roles, and invitations, shipped as a Keel
3
+ * package. One line in `bootstrap/providers.ts`:
4
+ *
5
+ * app.register(TeamsServiceProvider)
6
+ *
7
+ * Then put your requests inside a team, and every `TenantModel` is scoped:
8
+ *
9
+ * // app/Http/Kernel.ts
10
+ * protected middleware = [sessionMiddleware(), teamContext()];
11
+ */
12
+ import { PackageProvider } from "../core/package.js";
13
+ export declare class TeamsServiceProvider extends PackageProvider {
14
+ readonly name = "teams";
15
+ private config;
16
+ register(): void;
17
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Teams — multi-tenancy, membership, roles, and invitations, shipped as a Keel
3
+ * package. One line in `bootstrap/providers.ts`:
4
+ *
5
+ * app.register(TeamsServiceProvider)
6
+ *
7
+ * Then put your requests inside a team, and every `TenantModel` is scoped:
8
+ *
9
+ * // app/Http/Kernel.ts
10
+ * protected middleware = [sessionMiddleware(), teamContext()];
11
+ */
12
+ import { fileURLToPath } from "node:url";
13
+ import { dirname, join } from "node:path";
14
+ import { PackageProvider } from "../core/package.js";
15
+ import { defaultConfig, resolveConfig } from "./config.js";
16
+ import { teamsMigration } from "./migration.js";
17
+ const here = dirname(fileURLToPath(import.meta.url));
18
+ export class TeamsServiceProvider extends PackageProvider {
19
+ name = "teams";
20
+ config;
21
+ register() {
22
+ this.mergeConfig("teams", defaultConfig);
23
+ this.config = resolveConfig();
24
+ this.migrations([teamsMigration(this.config.userTable)]);
25
+ this.publishes({ [join(here, "teams.config.stub")]: "config/teams.ts" }, "teams-config");
26
+ }
27
+ }
@@ -0,0 +1,24 @@
1
+ import { env } from "@shaferllc/keel/core";
2
+
3
+ /**
4
+ * Teams configuration — multi-tenancy, membership, roles, invitations.
5
+ * Published with `keel vendor:publish --tag teams-config`.
6
+ */
7
+ export default {
8
+ // The users table. Teams adds `current_team_id` to it and touches nothing else.
9
+ userTable: "users",
10
+
11
+ // Give every new user a team of their own. Worth leaving on even if the app feels
12
+ // single-user: a personal workspace is a team of one, and adding tenancy LATER
13
+ // means backfilling every table and rewriting every query.
14
+ personalTeams: true,
15
+
16
+ invitations: {
17
+ expiresInHours: 72,
18
+ url: "/invitations/:token",
19
+ },
20
+
21
+ mail: {
22
+ from: env("MAIL_FROM", undefined),
23
+ },
24
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * `TenantModel` — a model that belongs to a team, and can't be read or written
3
+ * outside one.
4
+ *
5
+ * class Post extends TenantModel {
6
+ * static table = "posts";
7
+ * }
8
+ *
9
+ * await Post.all(); // only the current team's posts
10
+ * await Post.create({ title: … }); // stamped with the current team
11
+ *
12
+ * Two halves, and both matter. The **read** side is a global scope, so every query
13
+ * the model builds is constrained — you cannot forget it, because you never write
14
+ * it. The **write** side is a `creating` hook that stamps the team id, so a row
15
+ * can't be born ownerless and then be visible to everyone (or to no one).
16
+ *
17
+ * Outside a team context both halves throw. See context.ts for why that's the point.
18
+ */
19
+ import { Model } from "../core/model.js";
20
+ /** The name of the scope, so it can be escaped by name: `Post.withoutGlobalScope(TENANT_SCOPE)`. */
21
+ export declare const TENANT_SCOPE = "tenant";
22
+ export declare class TenantModel extends Model {
23
+ /** The column holding the owning team. Override if your schema differs. */
24
+ static teamColumn: string;
25
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `TenantModel` — a model that belongs to a team, and can't be read or written
3
+ * outside one.
4
+ *
5
+ * class Post extends TenantModel {
6
+ * static table = "posts";
7
+ * }
8
+ *
9
+ * await Post.all(); // only the current team's posts
10
+ * await Post.create({ title: … }); // stamped with the current team
11
+ *
12
+ * Two halves, and both matter. The **read** side is a global scope, so every query
13
+ * the model builds is constrained — you cannot forget it, because you never write
14
+ * it. The **write** side is a `creating` hook that stamps the team id, so a row
15
+ * can't be born ownerless and then be visible to everyone (or to no one).
16
+ *
17
+ * Outside a team context both halves throw. See context.ts for why that's the point.
18
+ */
19
+ import { Model } from "../core/model.js";
20
+ import { addModelHook } from "../core/model-events.js";
21
+ import { currentTeamId } from "./context.js";
22
+ /** The name of the scope, so it can be escaped by name: `Post.withoutGlobalScope(TENANT_SCOPE)`. */
23
+ export const TENANT_SCOPE = "tenant";
24
+ export class TenantModel extends Model {
25
+ /** The column holding the owning team. Override if your schema differs. */
26
+ static teamColumn = "team_id";
27
+ }
28
+ // Registered on the base class, and inherited by every subclass — that inheritance
29
+ // is load-bearing. If it didn't hold, `Post.query()` would come back unconstrained
30
+ // and every tenant's rows would be readable, silently. (Both the scope and the hook
31
+ // walk the prototype chain; see model.ts / model-events.ts.)
32
+ TenantModel.addGlobalScope(TENANT_SCOPE, (query, model) => {
33
+ // `model` is the concrete subclass being queried, so each one gets its own
34
+ // teamColumn rather than the base class's.
35
+ query.where(model.teamColumn, currentTeamId());
36
+ });
37
+ addModelHook(TenantModel, "creating", (model) => {
38
+ const column = model.constructor.teamColumn;
39
+ const row = model;
40
+ // Don't overwrite an explicit team — a deliberate cross-team write (an admin tool,
41
+ // a transfer) already said what it meant. Everything else gets the current team.
42
+ if (row[column] === undefined || row[column] === null) {
43
+ row[column] = currentTeamId();
44
+ }
45
+ });