@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
|
@@ -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
|
+
});
|
package/docs/ai-manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shaferllc/keel",
|
|
3
|
-
"version": "0.81.
|
|
3
|
+
"version": "0.81.2",
|
|
4
4
|
"description": "The house framework for Node.js — a service container, providers, routing, JSX views, and a code-generating console.",
|
|
5
5
|
"repo": "https://github.com/shaferllc/keel",
|
|
6
6
|
"generated": "run `npm run build:ai` to regenerate",
|
|
@@ -75,6 +75,13 @@
|
|
|
75
75
|
"path": "docs/cache.md",
|
|
76
76
|
"example": "docs/examples/cache.ts"
|
|
77
77
|
},
|
|
78
|
+
{
|
|
79
|
+
"slug": "changelog",
|
|
80
|
+
"title": "Changelog",
|
|
81
|
+
"summary": "All notable changes to Keel are documented here.",
|
|
82
|
+
"path": "docs/changelog.md",
|
|
83
|
+
"example": null
|
|
84
|
+
},
|
|
78
85
|
{
|
|
79
86
|
"slug": "configuration",
|
|
80
87
|
"title": "Configuration",
|
|
@@ -362,6 +369,13 @@
|
|
|
362
369
|
"path": "docs/storage.md",
|
|
363
370
|
"example": "docs/examples/storage.ts"
|
|
364
371
|
},
|
|
372
|
+
{
|
|
373
|
+
"slug": "teams",
|
|
374
|
+
"title": "Teams",
|
|
375
|
+
"summary": "Multi-tenancy, membership, roles, and invitations — where a row belongs to a team, and one team can never see another's.",
|
|
376
|
+
"path": "docs/teams.md",
|
|
377
|
+
"example": "docs/examples/teams.ts"
|
|
378
|
+
},
|
|
365
379
|
{
|
|
366
380
|
"slug": "telemetry",
|
|
367
381
|
"title": "Telemetry",
|