@protobase/server 0.67.4 → 0.67.6

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 (32) hide show
  1. package/dist/better-auth/authenticator.js +16 -5
  2. package/dist/better-auth/create-auth.d.ts +13 -5
  3. package/dist/better-auth/create-auth.js +3 -3
  4. package/dist/better-auth/invitations/invitation-link-hook.js +9 -4
  5. package/dist/better-auth/organizations/options.d.ts +2 -2
  6. package/dist/better-auth/organizations/options.js +2 -3
  7. package/dist/better-auth/organizations/organization-plugin.js +4 -2
  8. package/dist/better-auth/token-standing.js +24 -12
  9. package/dist/better-auth/users/user-endpoints.js +2 -1
  10. package/dist/create-admin.d.ts +7 -2
  11. package/dist/create-admin.js +1 -1
  12. package/dist/mail/mailing-app.js +2 -1
  13. package/dist/package.js +1 -1
  14. package/dist/request-app-url.js +9 -0
  15. package/dist/types.d.ts +5 -1
  16. package/dist/user-tables/admin-user-tables.js +5 -3
  17. package/dist/user-tables/check-definition.js +9 -5
  18. package/dist/user-tables/definition-resource.d.ts +1 -0
  19. package/dist/user-tables/definition-resource.js +17 -8
  20. package/dist/user-tables/definitions-hook.js +9 -7
  21. package/dist/user-tables/definitions-resources.js +45 -26
  22. package/dist/user-tables/names.js +12 -6
  23. package/dist/user-tables/organization-access.js +14 -0
  24. package/dist/user-tables/owned-definitions.d.ts +4 -3
  25. package/dist/user-tables/owned-definitions.js +6 -5
  26. package/dist/user-tables/read-definitions.d.ts +6 -1
  27. package/dist/user-tables/read-definitions.js +1 -1
  28. package/dist/user-tables/schema-sql.js +37 -6
  29. package/dist/user-tables/schema.d.ts +2 -1
  30. package/dist/user-tables/schema.js +12 -7
  31. package/dist/user-tables/table-ddl.js +7 -2
  32. package/package.json +5 -4
@@ -47,11 +47,18 @@ const betterAuthAuthenticator = (options) => {
47
47
  const verify = async (request) => {
48
48
  const { payload, session } = await sessions(request);
49
49
  if (!auth) return session;
50
- const signIn = await standing(await auth.$context, payload, session);
51
- return signIn ? {
50
+ const { signIn, organizationRole } = await standing(await auth.$context, payload, session);
51
+ const withRole = organizationRole && session.tenant !== void 0 ? {
52
52
  ...session,
53
- signIn
53
+ organization: {
54
+ id: session.tenant,
55
+ role: organizationRole
56
+ }
54
57
  } : session;
58
+ return signIn ? {
59
+ ...withRole,
60
+ signIn
61
+ } : withRole;
55
62
  };
56
63
  if (!organizations) return verify;
57
64
  return async (request) => {
@@ -65,7 +72,10 @@ const betterAuthAuthenticator = (options) => {
65
72
  return {
66
73
  ...session,
67
74
  user,
68
- ...session.tenant !== void 0 && { organization: { id: session.tenant } },
75
+ ...session.tenant !== void 0 && { organization: {
76
+ id: session.tenant,
77
+ ...session.organization?.role && { role: session.organization.role }
78
+ } },
69
79
  ...across
70
80
  };
71
81
  };
@@ -81,7 +91,8 @@ const betterAuthAuthenticator = (options) => {
81
91
  issuedAt: payload.iat
82
92
  });
83
93
  const { sessionId: _, ...claims } = tokenClaims(payload, session.user.roles, organizations ? session.tenant : void 0);
84
- await checkUserStanding(context, claims, organizations);
94
+ const organizationRole = await checkUserStanding(context, claims, organizations);
95
+ return organizationRole ? { organizationRole } : {};
85
96
  };
86
97
  return Object.assign(withTrustedApps(own, issuer, options.apps ?? []), { forAudience: (audience) => sessionOf({
87
98
  audience,
@@ -590,7 +590,17 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
590
590
  userId: string;
591
591
  expiresAt: Date;
592
592
  token: string;
593
- ipAddress?: string | null | undefined;
593
+ ipAddress?
594
+ /**
595
+ * What the sign-in page leads with: `password`, the email address and a password, with emailing a code below it; or
596
+ * `emailCode`, the address and "Email me a sign-in code", with the password behind "Use a password", for apps whose
597
+ * people mostly never set one. Only while the policy leaves both on; default `password`.
598
+ */
599
+ /**
600
+ * What the sign-in page leads with: `password`, the email address and a password, with emailing a code below it; or
601
+ * `emailCode`, the address and "Email me a sign-in code", with the password behind "Use a password", for apps whose
602
+ * people mostly never set one. Only while the policy leaves both on; default `password`.
603
+ */ : string | null | undefined;
594
604
  userAgent?: string | null | undefined;
595
605
  };
596
606
  };
@@ -1666,7 +1676,7 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
1666
1676
  email: string;
1667
1677
  emailVerified: boolean;
1668
1678
  name: string;
1669
- image? /** Public origin of the API, for example `https://admin.example.com`. Issuer and audience of the tokens; new passkeys are made for its host unless `passkeyDomain` names another. */ : string | null | undefined;
1679
+ image?: string | null | undefined;
1670
1680
  };
1671
1681
  };
1672
1682
  }>>[];
@@ -2411,9 +2421,7 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
2411
2421
  email: string;
2412
2422
  emailVerified: boolean;
2413
2423
  name: string;
2414
- image?
2415
- /** The RP ID of new passkeys, from `resolvePasskeyDomain`; default the host of `baseURL`. */
2416
- /** The RP ID of new passkeys, from `resolvePasskeyDomain`; default the host of `baseURL`. */ : string | null | undefined;
2424
+ image?: string | null | undefined;
2417
2425
  };
2418
2426
  };
2419
2427
  }>>[];
@@ -17,7 +17,7 @@ import { jsonOnlyOnMobile, mobileRequests } from "./mobile-requests.js";
17
17
  import { passwordLength } from "./new-account-password.js";
18
18
  import { platformMailer } from "../mail/platform-mailer.js";
19
19
  import { readOperatorSettings, resolveOperator } from "./operator-provider.js";
20
- import { defaultInvitationUrl } from "./invitations/invitation-link-hook.js";
20
+ import "./invitations/invitation-link-hook.js";
21
21
  import { lastAdminGuardPlugin } from "./last-admin-guard.js";
22
22
  import { setPasswordPath, setPasswordPlugin } from "./set-password.js";
23
23
  import { emailCodeOptions, twoFactorCodeOptions, unusedEmailCodePaths } from "./sign-in-mail.js";
@@ -271,7 +271,7 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
271
271
  definitions,
272
272
  defaultPolicy: signInPolicy,
273
273
  ...mail && { mail },
274
- invitationUrl: organizations?.invitationUrl ?? defaultInvitationUrl(options.baseURL),
274
+ ...organizations?.invitationUrl !== void 0 && { invitationUrl: organizations.invitationUrl },
275
275
  invitationSeconds: organizations?.invitationExpiresIn ?? 604800,
276
276
  ...organizations && { organizations }
277
277
  }),
@@ -410,7 +410,7 @@ const createAuth = (options, plugins = {}) => {
410
410
  const personalMail = resolvePersonalMail(options.personalMail ?? [], socialProviders, options.encryptOAuthTokens === true);
411
411
  const origins = resolveOrigins(options.baseURL, options.origins ?? platformOrigins());
412
412
  const passkeyDomain = resolvePasskeyDomain(options.passkeyDomain ?? platformPasskeyDomain(), [options.baseURL, ...origins]);
413
- const organizations = options.organizations && resolveOrganizations(options.organizations, definitions, options.baseURL);
413
+ const organizations = options.organizations && resolveOrganizations(options.organizations, definitions);
414
414
  const mcp = options.mcp && resolveMcp(options.baseURL, options.mcp);
415
415
  let instance;
416
416
  const context = () => instance?.$context ?? Promise.reject(/* @__PURE__ */ new Error("The auth instance is not made yet"));
@@ -1,3 +1,4 @@
1
+ import { requestAppUrl } from "../../request-app-url.js";
1
2
  import { invitationLink, invitationToken } from "./invitation-token.js";
2
3
  import { createAuthMiddleware } from "better-auth/api";
3
4
  //#region src/better-auth/invitations/invitation-link-hook.ts
@@ -9,7 +10,7 @@ import { createAuthMiddleware } from "better-auth/api";
9
10
  const invitationLinkHook = (paths, invitationUrl) => ({
10
11
  matcher: (context) => paths.includes(context.path ?? ""),
11
12
  handler: createAuthMiddleware(async (ctx) => {
12
- const answer = await withInvitationLink(ctx.context.returned, ctx.context.secret, invitationUrl);
13
+ const answer = await withInvitationLink(ctx.context.returned, ctx.context.secret, invitationPage(invitationUrl, ctx.context.baseURL));
13
14
  if (answer) ctx.context.returned = answer;
14
15
  })
15
16
  });
@@ -26,9 +27,13 @@ const withInvitationLink = async (returned, secret, invitationUrl) => {
26
27
  link: invitationLink(invitationUrl, token)
27
28
  };
28
29
  };
29
- /** The page an invitation link opens, with `?token=`, unless the app names another. */
30
- const defaultInvitationUrl = (baseURL) => `${baseURL.replace(/\/$/, "")}/-/invitation`;
30
+ /**
31
+ * The page an invitation link opens, with `?token=`: the app's own `invitationUrl`, or `/-/invitation` on the origin
32
+ * the request came to, from Better Auth's `ctx.context.baseURL` (`requestAppUrl`), so a link made on one of the app's
33
+ * origins opens there.
34
+ */
35
+ const invitationPage = (invitationUrl, requestURL) => invitationUrl ?? requestAppUrl(requestURL, "/-/invitation");
31
36
  /** How long an invitation works unless the app says otherwise: 7 days, in seconds. */
32
37
  const defaultInvitationSeconds = 604800;
33
38
  //#endregion
34
- export { defaultInvitationSeconds, defaultInvitationUrl, invitationLinkHook, withInvitationLink };
39
+ export { defaultInvitationSeconds, invitationLinkHook, invitationPage, withInvitationLink };
@@ -22,7 +22,7 @@ export type OrganizationsOptions = {
22
22
  * rows in any `.tenant` resource; `'cascade'` leaves it to the project's foreign keys.
23
23
  */
24
24
  deleteRecords?: 'refuse' | 'cascade';
25
- /** The page an invitation link opens, with `?token=`. Default `{baseURL}/-/invitation`. */
25
+ /** The page an invitation link opens, with `?token=`. Default `/-/invitation` on the origin the request came to. */
26
26
  invitationUrl?: string;
27
27
  /** Runs after an organization is created, with its owner's membership, for example to add the app's own row for it. */
28
28
  onCreated?: (input: {
@@ -42,7 +42,7 @@ export type ResolvedOrganizations = {
42
42
  membershipLimit: number;
43
43
  generateId: () => string | Promise<string>;
44
44
  deleteRecords: 'refuse' | 'cascade';
45
- invitationUrl: string;
45
+ invitationUrl?: string;
46
46
  options: OrganizationsOptions;
47
47
  };
48
48
  //#endregion
@@ -1,7 +1,6 @@
1
1
  import { membershipRoles } from "./role-definitions.js";
2
- import { defaultInvitationUrl } from "../invitations/invitation-link-hook.js";
3
2
  //#region src/better-auth/organizations/options.ts
4
- const resolveOrganizations = (options, definitions, baseURL) => {
3
+ const resolveOrganizations = (options, definitions) => {
5
4
  const holdable = membershipRoles(definitions);
6
5
  const creatorAppRoles = options.creatorAppRoles === void 0 || options.creatorAppRoles === "all" ? holdable : options.creatorAppRoles;
7
6
  const unknown = creatorAppRoles.find((name) => !holdable.includes(name));
@@ -16,7 +15,7 @@ const resolveOrganizations = (options, definitions, baseURL) => {
16
15
  membershipLimit: options.membershipLimit ?? 1e3,
17
16
  generateId: options.generateId ?? (() => crypto.randomUUID()),
18
17
  deleteRecords: options.deleteRecords ?? "refuse",
19
- invitationUrl: options.invitationUrl ?? defaultInvitationUrl(baseURL),
18
+ ...options.invitationUrl !== void 0 && { invitationUrl: options.invitationUrl },
20
19
  options
21
20
  };
22
21
  };
@@ -2,8 +2,10 @@ import "./role-definitions.js";
2
2
  import { checkAppRoleChange, effectiveRoles, globalRoles, parseAppRoles } from "./app-roles.js";
3
3
  import { findMember } from "./store.js";
4
4
  import { invitationLink, invitationToken } from "../invitations/invitation-token.js";
5
+ import { invitationPage } from "../invitations/invitation-link-hook.js";
5
6
  import { sendAuthEmail } from "../auth-mail/send-auth-email.js";
6
7
  import { APIError } from "better-auth/api";
8
+ import { getCurrentAuthEndpointContext } from "@better-auth/core/context";
7
9
  import { organization } from "better-auth/plugins/organization";
8
10
  //#region src/better-auth/organizations/organization-plugin.ts
9
11
  /** The snake_case names of the organization plugin's tables and columns, with the app roles of members and invitations. */
@@ -112,8 +114,8 @@ const organizationPlugin = ({ resolved, secret, mail, context }) => {
112
114
  cancelPendingInvitationsOnReInvite: true,
113
115
  schema: organizationNames,
114
116
  ...mail && { sendInvitationEmail: async ({ invitation, organization, inviter }) => {
115
- const token = await invitationToken(secret, invitation);
116
- const url = invitationLink(resolved.invitationUrl, token);
117
+ const page = invitationPage(resolved.invitationUrl, getCurrentAuthEndpointContext().context.baseURL);
118
+ const url = invitationLink(page, await invitationToken(secret, invitation));
117
119
  await sendAuthEmail(mail, "invitationEmail", invitation.email, {
118
120
  inviter: inviter.user.name || inviter.user.email,
119
121
  organization: organization.name,
@@ -2,7 +2,7 @@ import { unauthorized } from "../problem.js";
2
2
  import { noGlobalRole } from "./organizations/role-definitions.js";
3
3
  import { parseRoles } from "./parse-roles.js";
4
4
  import { effectiveRoles, globalRoles } from "./organizations/app-roles.js";
5
- import { findMember, findOrganization } from "./organizations/store.js";
5
+ import { findMember, findOrganization, organizationRoleOf } from "./organizations/store.js";
6
6
  import { isStaffSession } from "./staff-checks.js";
7
7
  import { strongSignInAt } from "./strong-sign-ins.js";
8
8
  //#region src/better-auth/token-standing.ts
@@ -23,20 +23,24 @@ const tokenClaims = (payload, roles, tenant) => ({
23
23
  const revoked = () => unauthorized("The bearer token was revoked: get a new one");
24
24
  const bannedNow = (user, now) => Boolean(user.banned) && (!user.banExpires || user.banExpires > now);
25
25
  const heldWhere = async ({ adapter }, user, organizations, organizationId) => {
26
- if (!organizations) return parseRoles(user.role);
26
+ if (!organizations) return { roles: parseRoles(user.role) };
27
27
  const global = globalRoles(user.role);
28
- if (!organizationId) return global;
28
+ if (!organizationId) return { roles: global };
29
29
  const member = await findMember(adapter, {
30
30
  organizationId,
31
31
  userId: user.id
32
32
  });
33
- if (member) return effectiveRoles(global, member.appRoles);
34
- return global.length > 0 && await findOrganization(adapter, organizationId) !== null ? global : void 0;
33
+ if (member) return {
34
+ roles: effectiveRoles(global, member.appRoles),
35
+ organizationRole: organizationRoleOf(member)
36
+ };
37
+ return global.length > 0 && await findOrganization(adapter, organizationId) !== null ? { roles: global } : void 0;
35
38
  };
36
39
  /**
37
40
  * Whether a token's user still stands, read from the store: they exist and are not banned, and each role the token
38
41
  * carries is one they still hold where it works. The part of `checkTokenStanding` an MCP access token gets, whose
39
- * grant outlives the browser session that made it.
42
+ * grant outlives the browser session that made it. Answers their organization role where the token works, as the
43
+ * store holds it now, `undefined` for someone who is no member there.
40
44
  */
41
45
  const checkUserStanding = async (context, claims, organizations, now = /* @__PURE__ */ new Date()) => {
42
46
  const user = await context.adapter.findOne({
@@ -48,14 +52,16 @@ const checkUserStanding = async (context, claims, organizations, now = /* @__PUR
48
52
  });
49
53
  if (!user || bannedNow(user, now)) throw revoked();
50
54
  const held = await heldWhere(context, user, organizations, claims.organizationId);
51
- if (!held || claims.roles.some((role) => !held.includes(role))) throw revoked();
55
+ if (!held || claims.roles.some((role) => !held.roles.includes(role))) throw revoked();
56
+ return held.organizationRole;
52
57
  };
53
58
  /**
54
59
  * Whether a token still stands, read from the store on every request: its session is not signed out or expired, and
55
60
  * its user still stands (`checkUserStanding`). A token that fails is a 401, so the client asks its session for a new
56
61
  * one, which carries the roles as they are now or is refused. Answers the sign-in the token's session stands for
57
62
  * (`signInOf`), `undefined` for a token without one; the user's own one also reads, when asked, when it last proved a
58
- * passkey or a second step (`strongSignInAt`).
63
+ * passkey or a second step (`strongSignInAt`). Answers it with the user's organization role where the token works
64
+ * (`checkUserStanding`).
59
65
  */
60
66
  const checkTokenStanding = async (context, claims, organizations, now = /* @__PURE__ */ new Date()) => {
61
67
  const session = claims.sessionId === void 0 ? void 0 : await context.adapter.findOne({
@@ -66,12 +72,18 @@ const checkTokenStanding = async (context, claims, organizations, now = /* @__PU
66
72
  }]
67
73
  });
68
74
  if (claims.sessionId !== void 0 && (!session || session.userId !== claims.userId || session.expiresAt <= now)) throw revoked();
69
- await checkUserStanding(context, claims, organizations, now);
75
+ const organizationRole = await checkUserStanding(context, claims, organizations, now);
70
76
  const signIn = session ? signInOf(session, claims.sessionId) : void 0;
71
- if (!signIn || signIn.staff) return signIn;
77
+ if (!signIn || signIn.staff) return {
78
+ signIn,
79
+ ...organizationRole && { organizationRole }
80
+ };
72
81
  return {
73
- ...signIn,
74
- strongAt: () => strongSignInAt(context.internalAdapter, claims.sessionId)
82
+ signIn: {
83
+ ...signIn,
84
+ strongAt: () => strongSignInAt(context.internalAdapter, claims.sessionId)
85
+ },
86
+ ...organizationRole && { organizationRole }
75
87
  };
76
88
  };
77
89
  //#endregion
@@ -1,5 +1,6 @@
1
1
  import { adminOnly } from "../admin-only.js";
2
2
  import { invitationLink, invitationToken } from "../invitations/invitation-token.js";
3
+ import { invitationPage } from "../invitations/invitation-link-hook.js";
3
4
  import { sendAuthEmail } from "../auth-mail/send-auth-email.js";
4
5
  import { createAppInvitation, deleteAppInvitation, listAppInvitations } from "./app-invitation-store.js";
5
6
  import { roleLabels } from "../roles/role-labels.js";
@@ -46,7 +47,7 @@ const userEndpoints = ({ definitions, mail, invitationUrl, invitationSeconds })
46
47
  inviterId: inviter.id
47
48
  }, invitationSeconds);
48
49
  if (mail) {
49
- const url = invitationLink(invitationUrl, await invitationToken(ctx.context.secret, invitation));
50
+ const url = invitationLink(invitationPage(invitationUrl, ctx.context.baseURL), await invitationToken(ctx.context.secret, invitation));
50
51
  await sendAuthEmail(mail, "invitationEmail", invitation.email, {
51
52
  inviter: inviter.name || inviter.email,
52
53
  url,
@@ -27,8 +27,13 @@ import { UpgradeWebSocket } from "hono/ws";
27
27
  export type CreateAdminInput = {
28
28
  resources: ResourceSource[];
29
29
  views?: ViewSource[];
30
- /** The tables users add, from their definitions (`userTableResources`): one a resource here or a built-in names is left out with a warning. */
31
- userTables?: UserTableSources;
30
+ /**
31
+ * The tables users add, from their definitions (`userTableResources`): one a resource here or a built-in names is left
32
+ * out with a warning. `organizations`, by default whether `auth` has them, keeps each definition in its organization.
33
+ */
34
+ userTables?: UserTableSources & {
35
+ organizations?: boolean;
36
+ };
32
37
  /** The `userMenu(...)` export of the config module: pages under the signed-in user in the sidebar. */
33
38
  userMenu?: UserMenuSource;
34
39
  /** The `bottomBar(...)` exports of the config module, in order: tabs on phones for the roles each names. */
@@ -138,7 +138,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], userTables
138
138
  notifications: notifications !== void 0,
139
139
  ...events && { events }
140
140
  });
141
- const served = adminUserTables(userTables, [...appModels, ...builtIn.resources.map((resource) => resource.toModel())].map((model) => model.name), options.roles !== void 0);
141
+ const served = adminUserTables(userTables, [...appModels, ...builtIn.resources.map((resource) => resource.toModel())].map((model) => model.name), options.roles !== void 0, userTables?.organizations ?? Boolean(auth?.organizations));
142
142
  for (const warning of served.warnings) console.warn(warning);
143
143
  const resources = [
144
144
  ...appResources,
@@ -1,3 +1,4 @@
1
+ import { requestAppUrl } from "../request-app-url.js";
1
2
  //#region src/mail/mailing-app.ts
2
3
  /**
3
4
  * The app as an auth email names it: `appName`, and the home page on the origin of `requestURL`, a URL on the origin
@@ -5,7 +6,7 @@
5
6
  */
6
7
  const mailingApp = ({ appName, baseURL }, requestURL) => ({
7
8
  name: appName,
8
- url: new URL("/", requestURL ?? baseURL).href
9
+ url: requestAppUrl(requestURL ?? baseURL, "/")
9
10
  });
10
11
  //#endregion
11
12
  export { mailingApp };
package/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.67.4";
2
+ var version = "0.67.6";
3
3
  //#endregion
4
4
  export { version };
@@ -0,0 +1,9 @@
1
+ //#region src/request-app-url.ts
2
+ /**
3
+ * The app's page at `path`, which starts with `/`, on the origin of `requestURL`: a URL Better Auth resolved for the
4
+ * request (`ctx.context.baseURL`), on the request's host when that is one of the app's origins and on `baseURL`'s
5
+ * otherwise. Only its origin counts, so Better Auth's base path, such as `/api/auth`, never reaches the page.
6
+ */
7
+ const requestAppUrl = (requestURL, path) => new URL(path, requestURL).href;
8
+ //#endregion
9
+ export { requestAppUrl };
package/dist/types.d.ts CHANGED
@@ -11,9 +11,13 @@ export type User = {
11
11
  id: string | number;
12
12
  roles: string[];
13
13
  };
14
- /** The organization a request works in, with organizations on: the same value as `tenant`. */
14
+ /**
15
+ * The organization a request works in, with organizations on: the same value as `tenant`, and the caller's organization
16
+ * role there, read from the store for this request; absent for someone who is no member there.
17
+ */
15
18
  export type SessionOrganization = {
16
19
  id: TenantValue;
20
+ role?: 'owner' | 'admin' | 'member';
17
21
  };
18
22
  /** A tenant the person is a member of, with the roles they act with there. */
19
23
  export type Membership = {
@@ -5,9 +5,10 @@ import { servedUserTables } from "./served-user-tables.js";
5
5
  /**
6
6
  * What the tables users add bring to the admin, with `userTables` on: the resources over their definitions, through
7
7
  * which they are created, each user table a name in code (`codeNames`, the app's and Protobase's) does not take, and the
8
- * hook that creates their tables and columns. Without `tables`, nothing.
8
+ * hook that creates their tables and columns. Without `tables`, nothing. With `organizations`, each definition belongs
9
+ * to the organization it was made in (`definitionsResources`).
9
10
  */
10
- const adminUserTables = (tables, codeNames, roles) => {
11
+ const adminUserTables = (tables, codeNames, roles, organizations = false) => {
11
12
  if (!tables) return {
12
13
  resources: [],
13
14
  views: [],
@@ -16,7 +17,8 @@ const adminUserTables = (tables, codeNames, roles) => {
16
17
  };
17
18
  const definitions = definitionsResources({
18
19
  roles,
19
- taken: codeNames
20
+ taken: codeNames,
21
+ organizations
20
22
  });
21
23
  const served = servedUserTables(tables, [
22
24
  ...codeNames,
@@ -1,10 +1,10 @@
1
- import { definitionColumnTypes, definitionFieldTypes, isUserTableName, userTablesSchema } from "./names.js";
1
+ import { definitionColumnTypes, definitionFieldTypes, isUserTableName, organizationColumn, userTablesSchema } from "./names.js";
2
2
  //#region src/user-tables/check-definition.ts
3
3
  const reservedFields = ["id", "etag"];
4
4
  const isFieldType = (type) => definitionFieldTypes.includes(type);
5
- const fieldProblem = (field, columns) => {
5
+ const fieldProblem = (field, columns, organization) => {
6
6
  if (!isUserTableName(field.name)) return `the field name ${JSON.stringify(field.name)} is not lowercase letters, digits and "_", starting with a letter, at most 63 bytes`;
7
- if (reservedFields.includes(field.name)) return `the field name "${field.name}" is reserved`;
7
+ if (reservedFields.includes(field.name) || organization && field.name === "organization_id") return `the field name "${field.name}" is reserved`;
8
8
  if (!isFieldType(field.type)) return `the field "${field.name}" has the type ${JSON.stringify(field.type)}, which is not one of ${definitionFieldTypes.join(", ")}`;
9
9
  if (field.type === "decimal" && (field.precision === null || field.scale === null)) return `the decimal field "${field.name}" has no precision and scale`;
10
10
  const column = columns.get(field.name);
@@ -15,7 +15,8 @@ const fieldProblem = (field, columns) => {
15
15
  /**
16
16
  * The definition checked against the user tables schema, or the first thing wrong with it: every name a safe
17
17
  * identifier, every type one of the eight, its table there with an `id bigint` primary key and a column of the right
18
- * type for each field, and its title a text field of it.
18
+ * type for each field, and its title a text field of it. An organization's table also has its `organization_id text
19
+ * not null` column, which `.tenant` scopes its records by.
19
20
  */
20
21
  const checkDefinition = (definition, tables) => {
21
22
  const problem = (text) => ({ problem: `The user table ${JSON.stringify(definition.name)} is not served: ${text}.` });
@@ -24,11 +25,14 @@ const checkDefinition = (definition, tables) => {
24
25
  if (!columns) return problem(`the table ${userTablesSchema}.${definition.name} does not exist`);
25
26
  const id = columns.get("id");
26
27
  if (!id || id.type !== "bigint" || !id.key) return problem(`${userTablesSchema}.${definition.name} has no "id" bigint primary key`);
28
+ const organization = definition.organization_id !== null;
29
+ const tenant = columns.get(organizationColumn);
30
+ if (organization && (!tenant || tenant.type !== "text" || !tenant.notNull)) return problem(`${userTablesSchema}.${definition.name} has no "${organizationColumn}" text NOT NULL column for its organization`);
27
31
  const seen = /* @__PURE__ */ new Set();
28
32
  for (const field of definition.fields) {
29
33
  if (seen.has(field.name)) return problem(`the field "${field.name}" is defined twice`);
30
34
  seen.add(field.name);
31
- const found = fieldProblem(field, columns);
35
+ const found = fieldProblem(field, columns, organization);
32
36
  if (found) return problem(found);
33
37
  }
34
38
  const title = definition.fields.find((field) => field.name === definition.title_field);
@@ -6,6 +6,7 @@ export declare const maxTextLength = 500;
6
6
  /**
7
7
  * The resource and view of a checked definition over `user_tables.<name>`, with the indexes its table has (none before
8
8
  * they are read): built with the schema builders, so it is checked, accessed and guarded as a resource of the config.
9
+ * An organization's table is a `.tenant` resource over its `organization_id`.
9
10
  */
10
11
  export declare const definitionResource: (definition: CheckedDefinition, indexes?: TableIndexes) => {
11
12
  resource: import("@protobase/schema").ResourceBuilder<any, import("@protobase/schema").Ref<string, unknown>>;
@@ -1,4 +1,5 @@
1
- import { userTablesSchema } from "./names.js";
1
+ import { organizationColumn, userTablesSchema } from "./names.js";
2
+ import { organizationAdminsOf } from "./organization-access.js";
2
3
  import { f, resource, view } from "@protobase/schema";
3
4
  import { indexServes } from "@protobase/query";
4
5
  //#region src/user-tables/definition-resource.ts
@@ -31,6 +32,7 @@ const fieldOf = (field, indexes) => {
31
32
  /**
32
33
  * The resource and view of a checked definition over `user_tables.<name>`, with the indexes its table has (none before
33
34
  * they are read): built with the schema builders, so it is checked, accessed and guarded as a resource of the config.
35
+ * An organization's table is a `.tenant` resource over its `organization_id`.
34
36
  */
35
37
  const definitionResource = (definition, indexes = {
36
38
  kind: "table",
@@ -38,15 +40,22 @@ const definitionResource = (definition, indexes = {
38
40
  }) => {
39
41
  const key = f.bigint().readOnly().dbDefault();
40
42
  const keyed = served(indexes, "id", "btree") ? key.filterable().sortable() : key;
41
- const fields = Object.fromEntries([["id", keyed], ...definition.fields.map((field) => [field.name, fieldOf(field, indexes)])]);
43
+ const organization = definition.organization_id === null ? [] : [[organizationColumn, f.text()]];
44
+ const fields = Object.fromEntries([
45
+ ["id", keyed],
46
+ ...organization,
47
+ ...definition.fields.map((field) => [field.name, fieldOf(field, indexes)])
48
+ ]);
42
49
  const title = definition.fields.find((field) => field.name === definition.title_field);
43
- const base = resource(definition.name).table(`${userTablesSchema}.${definition.name}`).fields(fields).primaryKey((r) => r.id).explicitGrants().access({
44
- list: admins,
45
- read: admins,
46
- create: admins,
47
- update: admins,
48
- delete: admins
50
+ const who = definition.organization_id === null ? admins : organizationAdminsOf(definition.organization_id);
51
+ const accessed = resource(definition.name).table(`${userTablesSchema}.${definition.name}`).fields(fields).primaryKey((r) => r.id).explicitGrants().access({
52
+ list: who,
53
+ read: who,
54
+ create: who,
55
+ update: who,
56
+ delete: who
49
57
  });
58
+ const base = definition.organization_id === null ? accessed : accessed.tenant((r) => r[organizationColumn]);
50
59
  const searchable = title && served(indexes, title.name, "trigram") ? base.search((r) => [r[title.name]]) : base;
51
60
  const labelled = view(definition.name).names({
52
61
  singular: definition.singular_label,
@@ -4,7 +4,7 @@ import { checkDefinition } from "./check-definition.js";
4
4
  import { definitionResource } from "./definition-resource.js";
5
5
  import "./definitions-resources.js";
6
6
  import { readDefinitions, readUserTableColumns } from "./read-definitions.js";
7
- import { createTableSql, fieldSql, trigramIndexSql } from "./table-ddl.js";
7
+ import { createOrganizationTableSql, createTableSql, fieldSql, trigramIndexSql } from "./table-ddl.js";
8
8
  import { indexServes, tableIndexes } from "@protobase/query";
9
9
  import { sql } from "kysely";
10
10
  //#region src/user-tables/definitions-hook.ts
@@ -15,8 +15,9 @@ const refuse = (field, message) => badRequest("invalid-record", `The request bod
15
15
  }] });
16
16
  const tablesTable = sql.table(`${definitionsSchema}.tables`);
17
17
  const raiseVersion = (trx, tableId) => sql`update ${tablesTable} set version = version + 1 where id = ${tableId}`.execute(trx);
18
- const lockedTable = async (trx, tableId) => {
19
- const { rows } = await sql`select name, title_field from ${tablesTable} where id = ${tableId} for update`.execute(trx);
18
+ const lockedTable = async (trx, tableId, organizationId) => {
19
+ const { rows } = await sql`select table_name as name from ${tablesTable} where id = ${tableId} and tenant_key = coalesce(${organizationId ?? null}::text, '-') for update`.execute(trx);
20
+ if (!rows[0]) throw refuse("table_id", "There is no such table");
20
21
  return rows[0];
21
22
  };
22
23
  const builtDefinition = async (trx, tableId) => {
@@ -33,13 +34,14 @@ const builtDefinition = async (trx, tableId) => {
33
34
  };
34
35
  const createTable = async (trx, table) => {
35
36
  if (table.title_field != null) throw refuse("title_field", "Set the title field once its field is added");
36
- const { rows } = await sql`select to_regclass(format('%I.%I', ${userTablesSchema}::text, ${table.name}::text)) is not null as found`.execute(trx);
37
- if (rows[0].found) throw refuse("name", `The database already has a table "${table.name}" for the tables users add`);
38
- await createTableSql(table.name).execute(trx);
37
+ const { rows } = await sql`select to_regclass(format('%I.%I', ${userTablesSchema}::text, ${table.table_name}::text)) is not null as found`.execute(trx);
38
+ if (rows[0].found) throw refuse("name", `The database already has a table "${table.table_name}" for the tables users add`);
39
+ const statements = table.organization_id == null ? [createTableSql(table.table_name)] : createOrganizationTableSql(table.table_name);
40
+ for (const statement of statements) await statement.execute(trx);
39
41
  await builtDefinition(trx, table.id);
40
42
  };
41
43
  const addField = async (trx, field, taken) => {
42
- const table = await lockedTable(trx, field.table_id);
44
+ const table = await lockedTable(trx, field.table_id, field.organization_id);
43
45
  if (taken.includes(table.name)) throw refuse("table_id", `"${table.name}" is a resource in the app's code now, which owns its fields`);
44
46
  const { rows } = await sql`select exists (select from pg_attribute a join pg_class c on c.oid = a.attrelid join pg_namespace n on n.oid = c.relnamespace
45
47
  where n.nspname = ${userTablesSchema} and c.relname = ${table.name} and a.attname = ${field.name} and a.attnum > 0 and not a.attisdropped) as found`.execute(trx);
@@ -1,4 +1,5 @@
1
- import { definitionFieldTypes, definitionsSchema, nameProblem } from "./names.js";
1
+ import { definitionFieldTypes, definitionsSchema, nameProblem, organizationColumn } from "./names.js";
2
+ import { organizationAdmins } from "./organization-access.js";
2
3
  import { f, resource } from "@protobase/schema";
3
4
  //#region src/user-tables/definitions-resources.ts
4
5
  /** The names of the resources over the definitions: a table users add takes neither. */
@@ -7,13 +8,20 @@ const fieldsResourceName = "user_table_fields";
7
8
  const admins = ({ user }) => user?.roles?.includes("admin") ?? false;
8
9
  const never = () => false;
9
10
  const createOnly = { update: never };
10
- const accessOf = (roles) => roles ? { delete: never } : {
11
+ const accessOf = (roles, organizations) => organizations ? {
12
+ list: organizationAdmins,
13
+ read: organizationAdmins,
14
+ create: organizationAdmins,
15
+ update: organizationAdmins,
16
+ delete: never
17
+ } : roles ? { delete: never } : {
11
18
  list: admins,
12
19
  read: admins,
13
20
  create: admins,
14
21
  update: admins,
15
22
  delete: never
16
23
  };
24
+ const withOrganization = (source, organizations) => organizations ? source.tenant((r) => r[organizationColumn]) : source;
17
25
  const issue = (field, problem) => problem ? [{
18
26
  field,
19
27
  message: problem
@@ -22,32 +30,43 @@ const issue = (field, problem) => problem ? [{
22
30
  * The definitions of the tables users add, as resources: a table at `/user_tables/<id>`, each of its fields at
23
31
  * `/user_table_fields/<table_id>,<name>`. Writing one creates its table or column in the database, in the same
24
32
  * transaction (`definitionsHook`). `taken` holds the names of the app's resources and those Protobase adds, which a
25
- * new table may not have.
33
+ * new table may not have. With `organizations`, each belongs to the organization it was made in and only that one's
34
+ * owners and admins reach it; its table is `t<id>_<name>` (`table_name`), so the name is free in every organization.
26
35
  */
27
- const definitionsResources = ({ roles, taken }) => {
36
+ const definitionsResources = ({ roles, taken, organizations = false }) => {
37
+ const organization = organizations ? { [organizationColumn]: f.text() } : {};
38
+ const nameIssue = (name) => {
39
+ if (organizations) return nameProblem(name, "table", 42);
40
+ return nameProblem(name, "table") ?? (taken.includes(name) || ["user_tables", "user_table_fields"].includes(name) ? `"${name}" is the name of a resource of the app` : void 0);
41
+ };
42
+ const tables = resource(tablesResourceName).table(`${definitionsSchema}.tables`).fields({
43
+ id: f.bigint().readOnly().dbDefault().filterable().sortable(),
44
+ name: f.text().access(createOnly).filterable().sortable(),
45
+ singular_label: f.text().max(200),
46
+ plural_label: f.text().max(200),
47
+ title_field: f.text().optional(),
48
+ version: f.bigint().readOnly().dbDefault(),
49
+ table_name: f.text().readOnly().dbDefault(),
50
+ ...organization
51
+ }).primaryKey((r) => r.id).explicitGrants().access(accessOf(roles, organizations)).validate((table) => [...issue("name", nameIssue(table.name)), ...issue("title_field", table.title_field ? nameProblem(table.title_field, "field") : void 0)]);
52
+ const fields = resource(fieldsResourceName).table(`${definitionsSchema}.fields`).fields({
53
+ table_id: f.relation(tablesResourceName).access(createOnly).filterable(),
54
+ name: f.text().access(createOnly),
55
+ label: f.text().max(200),
56
+ type: f.enum(definitionFieldTypes).access(createOnly),
57
+ required: f.boolean().default(false).access(createOnly),
58
+ position: f.integer().sortable(),
59
+ precision: f.integer().optional().access(createOnly),
60
+ scale: f.integer().optional().access(createOnly),
61
+ ...organization
62
+ }).primaryKey((r) => [r.table_id, r.name]).explicitGrants().access(accessOf(roles, organizations)).validate((field) => [
63
+ ...issue("name", nameProblem(field.name, "field")),
64
+ ...issue("required", field.required ? "A field users add is optional: the records already there have no value for it" : void 0),
65
+ ...field.type === "decimal" ? [...issue("precision", field.precision == null || field.precision < 1 || field.precision > 1e3 ? "A decimal needs a precision from 1 to 1000" : void 0), ...issue("scale", field.scale == null || field.scale < 0 || field.precision != null && field.scale > field.precision ? "A decimal needs a scale from 0 to its precision" : void 0)] : [...issue("precision", field.precision == null ? void 0 : "Only a decimal has a precision"), ...issue("scale", field.scale == null ? void 0 : "Only a decimal has a scale")]
66
+ ]);
28
67
  return {
29
- tables: resource(tablesResourceName).table(`${definitionsSchema}.tables`).fields({
30
- id: f.bigint().readOnly().dbDefault().filterable().sortable(),
31
- name: f.text().access(createOnly).filterable().sortable(),
32
- singular_label: f.text().max(200),
33
- plural_label: f.text().max(200),
34
- title_field: f.text().optional(),
35
- version: f.bigint().readOnly().dbDefault()
36
- }).primaryKey((r) => r.id).explicitGrants().access(accessOf(roles)).validate((table) => [...issue("name", nameProblem(table.name, "table") ?? (taken.includes(table.name) || ["user_tables", "user_table_fields"].includes(table.name) ? `"${table.name}" is the name of a resource of the app` : void 0)), ...issue("title_field", table.title_field ? nameProblem(table.title_field, "field") : void 0)]),
37
- fields: resource(fieldsResourceName).table(`${definitionsSchema}.fields`).fields({
38
- table_id: f.relation(tablesResourceName).access(createOnly).filterable(),
39
- name: f.text().access(createOnly),
40
- label: f.text().max(200),
41
- type: f.enum(definitionFieldTypes).access(createOnly),
42
- required: f.boolean().default(false).access(createOnly),
43
- position: f.integer().sortable(),
44
- precision: f.integer().optional().access(createOnly),
45
- scale: f.integer().optional().access(createOnly)
46
- }).primaryKey((r) => [r.table_id, r.name]).explicitGrants().access(accessOf(roles)).validate((field) => [
47
- ...issue("name", nameProblem(field.name, "field")),
48
- ...issue("required", field.required ? "A field users add is optional: the records already there have no value for it" : void 0),
49
- ...field.type === "decimal" ? [...issue("precision", field.precision == null || field.precision < 1 || field.precision > 1e3 ? "A decimal needs a precision from 1 to 1000" : void 0), ...issue("scale", field.scale == null || field.scale < 0 || field.precision != null && field.scale > field.precision ? "A decimal needs a scale from 0 to its precision" : void 0)] : [...issue("precision", field.precision == null ? void 0 : "Only a decimal has a precision"), ...issue("scale", field.scale == null ? void 0 : "Only a decimal has a scale")]
50
- ])
68
+ tables: withOrganization(tables, organizations),
69
+ fields: withOrganization(fields, organizations)
51
70
  };
52
71
  };
53
72
  //#endregion
@@ -19,13 +19,19 @@ const definitionColumnTypes = {
19
19
  };
20
20
  const definitionFieldTypes = Object.keys(definitionColumnTypes);
21
21
  const isUserTableName = (name) => userTableName.test(name) && new TextEncoder().encode(name).length <= 63;
22
- /** The names a field never takes: the key's, and the API's for the record version. */
23
- const reservedFieldNames = ["id", "etag"];
24
- /** What is wrong with a name a table or field users add is given, or `undefined` when it may have it. */
25
- const nameProblem = (name, kind) => {
26
- if (typeof name !== "string" || !isUserTableName(name)) return "Use lowercase letters, digits and \"_\", starting with a letter, at most 63 bytes";
22
+ /** The column that holds the organization of each record of a table users add with organizations, and of each definition. */
23
+ const organizationColumn = "organization_id";
24
+ /** The names a field never takes: the key's, the API's for the record version, and the organization's. */
25
+ const reservedFieldNames = [
26
+ "id",
27
+ "etag",
28
+ organizationColumn
29
+ ];
30
+ /** What is wrong with a name a table or field users add is given, or `undefined` when it may have it; `maxBytes` defaults to 63. */
31
+ const nameProblem = (name, kind, maxBytes = 63) => {
32
+ if (typeof name !== "string" || !isUserTableName(name) || new TextEncoder().encode(name).length > maxBytes) return `Use lowercase letters, digits and "_", starting with a letter, at most ${maxBytes} bytes`;
27
33
  if (reservedWords.has(name)) return `"${name}" is a word SQL reserves`;
28
34
  if (kind === "field" && reservedFieldNames.includes(name)) return `"${name}" is the name of a field every table has`;
29
35
  };
30
36
  //#endregion
31
- export { definitionColumnTypes, definitionFieldTypes, definitionsSchema, isUserTableName, nameProblem, reservedFieldNames, userTableName, userTablesSchema };
37
+ export { definitionColumnTypes, definitionFieldTypes, definitionsSchema, isUserTableName, nameProblem, organizationColumn, reservedFieldNames, userTableName, userTablesSchema };
@@ -0,0 +1,14 @@
1
+ //#region src/user-tables/organization-access.ts
2
+ /**
3
+ * The organization's owners and admins, as the auth store holds their membership for this request, and superusers
4
+ * working in it: no one without an organization, so a session that names none reaches nothing.
5
+ */
6
+ const organizationAdmins = ({ user, organization }) => {
7
+ if (!organization) return false;
8
+ if (user?.roles?.includes("admin")) return true;
9
+ return organization.role === "owner" || organization.role === "admin";
10
+ };
11
+ /** `organizationAdmins` of the one organization `organizationId`, the one a table users add belongs to. */
12
+ const organizationAdminsOf = (organizationId) => (context) => context.organization !== void 0 && String(context.organization.id) === organizationId && organizationAdmins(context);
13
+ //#endregion
14
+ export { organizationAdmins, organizationAdminsOf };
@@ -4,9 +4,10 @@ import { Db } from "@protobase/query";
4
4
  /** The user tables a resource in code owns, as `protobase eject` writes one: over `user_tables.<name>`, with that name. */
5
5
  export declare const ownedTableNames: (models: readonly ResourceModel[]) => string[];
6
6
  /**
7
- * The delete of the definitions of the user tables a resource in code owns, with their fields; their tables and rows
8
- * stay. `undefined` when no such definition is left. `protobase migration` writes it once the code that owns them has
9
- * deployed, so a definition goes in a later deploy than the one that stopped reading it.
7
+ * The delete of the definitions of the user tables a resource in code owns, with their fields, matched on the table
8
+ * they serve, so an organization's table of the same name stays; their tables and rows stay. `undefined` when no such
9
+ * definition is left. `protobase migration` writes it once the code that owns them has deployed, so a definition goes
10
+ * in a later deploy than the one that stopped reading it.
10
11
  */
11
12
  export declare const ownedDefinitionsMigration: (db: Db, models: readonly ResourceModel[]) => Promise<string | undefined>;
12
13
  //#endregion
@@ -5,18 +5,19 @@ import { sql } from "kysely";
5
5
  const ownedTableNames = (models) => models.filter((model) => model.table.schema === "user_tables" && model.table.name === model.name).map((model) => model.name);
6
6
  const literal = (text) => `'${text.replaceAll("'", "''")}'`;
7
7
  /**
8
- * The delete of the definitions of the user tables a resource in code owns, with their fields; their tables and rows
9
- * stay. `undefined` when no such definition is left. `protobase migration` writes it once the code that owns them has
10
- * deployed, so a definition goes in a later deploy than the one that stopped reading it.
8
+ * The delete of the definitions of the user tables a resource in code owns, with their fields, matched on the table
9
+ * they serve, so an organization's table of the same name stays; their tables and rows stay. `undefined` when no such
10
+ * definition is left. `protobase migration` writes it once the code that owns them has deployed, so a definition goes
11
+ * in a later deploy than the one that stopped reading it.
11
12
  */
12
13
  const ownedDefinitionsMigration = async (db, models) => {
13
14
  const owned = ownedTableNames(models);
14
15
  if (owned.length === 0) return;
15
16
  const { rows: found } = await sql`select to_regclass(${`${definitionsSchema}.tables`}) is not null as found`.execute(db);
16
17
  if (!found[0].found) return;
17
- const { rows } = await sql`select name from ${sql.table(`${definitionsSchema}.tables`)} where name = any(${owned}) order by name`.execute(db);
18
+ const { rows } = await sql`select table_name as name from ${sql.table(`${definitionsSchema}.tables`)} where table_name = any(${owned}) order by table_name`.execute(db);
18
19
  if (rows.length === 0) return;
19
- return `delete from ${definitionsSchema}.tables where name in (${rows.map((row) => literal(row.name)).join(", ")});`;
20
+ return `delete from ${definitionsSchema}.tables where table_name in (${rows.map((row) => literal(row.name)).join(", ")});`;
20
21
  };
21
22
  //#endregion
22
23
  export { ownedDefinitionsMigration, ownedTableNames };
@@ -9,10 +9,15 @@ export type StoredField = {
9
9
  precision: number | null;
10
10
  scale: number | null;
11
11
  };
12
- /** A stored definition of a table users add, as its rows hold it, with its fields in order. */
12
+ /**
13
+ * A stored definition of a table users add, as its rows hold it, with its fields in order. `name` is its table's name in
14
+ * the user tables schema and its resource's (`table_name`); `organization_id` the organization it belongs to, `null`
15
+ * without organizations.
16
+ */
13
17
  export type StoredDefinition = {
14
18
  id: string;
15
19
  name: string;
20
+ organization_id: string | null;
16
21
  singular_label: string;
17
22
  plural_label: string;
18
23
  title_field: string | null;
@@ -3,7 +3,7 @@ import { sql } from "kysely";
3
3
  //#region src/user-tables/read-definitions.ts
4
4
  /** The stored definitions, by id, each with its fields by position. */
5
5
  const readDefinitions = async (db) => {
6
- const { rows } = await sql`select t.id::text as id, t.name, t.singular_label, t.plural_label, t.title_field, t.version::text as version,
6
+ const { rows } = await sql`select t.id::text as id, t.table_name as name, t.organization_id, t.singular_label, t.plural_label, t.title_field, t.version::text as version,
7
7
  coalesce((select json_agg(json_build_object('name', f.name, 'label', f.label, 'type', f.type, 'required', f.required, 'precision', f.precision, 'scale', f.scale) order by f.position, f.name)
8
8
  from ${sql.table(`${definitionsSchema}.fields`)} f where f.table_id = t.id), '[]') as fields
9
9
  from ${sql.table(`${definitionsSchema}.tables`)} t order by t.id`.execute(db);
@@ -1,20 +1,39 @@
1
1
  import { definitionFieldTypes, definitionsSchema, userTableName, userTablesSchema } from "./names.js";
2
2
  //#region src/user-tables/schema-sql.ts
3
3
  const nameCheck = (column) => `check (${column} ~ '${userTableName.source}' and octet_length(${column}) <= 63)`;
4
- /** The table of definitions: one row per table users add, its labels and a version, 1 when added, that each change of it raises. */
4
+ /**
5
+ * The table of definitions: one row per table users add, its labels and a version, 1 when added, that each change of it
6
+ * raises. With organizations a definition belongs to the one it was made in (`organization_id`), and its table is named
7
+ * `t<id>_<name>`, so two organizations can each have a table of one name; without, the table is the name. `table_name`
8
+ * is the table's name in the user tables schema and the name of its resource.
9
+ */
5
10
  const tablesSql = () => `create table ${definitionsSchema}.tables (
6
11
  id bigint generated always as identity primary key,
7
- name text not null unique ${nameCheck("name")},
12
+ name text not null ${nameCheck("name")},
13
+ organization_id text,
8
14
  singular_label text not null,
9
15
  plural_label text not null,
10
16
  title_field text ${nameCheck("title_field")},
11
- version bigint not null default 1 check (version > 0)
17
+ version bigint not null default 1 check (version > 0),
18
+ ${tableNameColumn},
19
+ ${tenantKeyColumn},
20
+ unique (id, tenant_key)
12
21
  )`;
22
+ const tableNameColumn = `table_name text not null unique generated always as (case when organization_id is null then name else 't' || id || '_' || name end) stored ${nameCheck("table_name")}`;
23
+ const tenantKeyColumn = "tenant_key text not null generated always as (coalesce(organization_id, '-')) stored";
24
+ /** A name is unique among the definitions of one organization, or of the app without organizations. */
25
+ const nameIndexesSql = () => [`create unique index tables_name_key on ${definitionsSchema}.tables (name) where organization_id is null`, `create unique index tables_organization_name_key on ${definitionsSchema}.tables (organization_id, name) where organization_id is not null`];
13
26
  /** Gives a definitions table made before new definitions started at version 1 that default. */
14
27
  const versionDefaultSql = () => `alter table ${definitionsSchema}.tables alter column version set default 1`;
15
- /** The fields of each definition, in order, each with its label and type; a decimal names its precision and scale. */
28
+ /**
29
+ * The fields of each definition, in order, each with its label and type; a decimal names its precision and scale. A
30
+ * field belongs to its table's organization: the foreign key compares both, so no field reaches another organization's
31
+ * table, or one of none.
32
+ */
16
33
  const fieldsSql = () => `create table ${definitionsSchema}.fields (
17
- table_id bigint not null references ${definitionsSchema}.tables (id) on delete cascade,
34
+ table_id bigint not null,
35
+ organization_id text,
36
+ ${tenantKeyColumn},
18
37
  name text not null ${nameCheck("name")} check (name not in ('id', 'etag')),
19
38
  label text not null,
20
39
  type text not null check (type in (${definitionFieldTypes.map((type) => `'${type}'`).join(", ")})),
@@ -23,8 +42,20 @@ const fieldsSql = () => `create table ${definitionsSchema}.fields (
23
42
  precision integer check (precision between 1 and 1000),
24
43
  scale integer check (scale between 0 and precision),
25
44
  primary key (table_id, name),
45
+ foreign key (table_id, tenant_key) references ${definitionsSchema}.tables (id, tenant_key) on delete cascade,
26
46
  check ((type = 'decimal') = (precision is not null and scale is not null))
27
47
  )`;
48
+ /**
49
+ * Brings a definitions table made before organizations to the shape of `tablesSql`: a name unique per organization and
50
+ * the served name.
51
+ */
52
+ const organizationsSql = () => [
53
+ `alter table ${definitionsSchema}.tables add column organization_id text, add column ${tableNameColumn}, add column ${tenantKeyColumn}, add unique (id, tenant_key)`,
54
+ `alter table ${definitionsSchema}.tables drop constraint tables_name_key`,
55
+ ...nameIndexesSql()
56
+ ];
57
+ /** Ties the fields made before organizations to their table's organization, as `fieldsSql` does. */
58
+ const fieldsOrganizationSql = () => `alter table ${definitionsSchema}.fields add column organization_id text, add column ${tenantKeyColumn}, add foreign key (table_id, tenant_key) references ${definitionsSchema}.tables (id, tenant_key) on delete cascade`;
28
59
  const schemasSql = () => [
29
60
  "create extension if not exists pg_trgm",
30
61
  `create schema if not exists ${definitionsSchema}`,
@@ -34,4 +65,4 @@ const schemasSql = () => [
34
65
  `grant usage, create on schema ${userTablesSchema} to current_user`
35
66
  ];
36
67
  //#endregion
37
- export { fieldsSql, schemasSql, tablesSql, versionDefaultSql };
68
+ export { fieldsOrganizationSql, fieldsSql, nameIndexesSql, organizationsSql, schemasSql, tablesSql, versionDefaultSql };
@@ -2,7 +2,8 @@ import { Db } from "@protobase/query";
2
2
  //#region src/user-tables/schema.d.ts
3
3
  /**
4
4
  * The SQL that adds what user tables need: pg_trgm, the definitions schema with its `tables` and `fields`, and the
5
- * schema the tables themselves live in. `undefined` when current. Nothing is dropped.
5
+ * schema the tables themselves live in. `undefined` when current. Nothing is dropped but the unique constraint on a
6
+ * definition's name, which a name unique per organization replaces.
6
7
  */
7
8
  export declare const userTablesMigration: (db: Db) => Promise<string | undefined>;
8
9
  /** Refuses to go on while the schemas of user tables are missing; the project's migrations add them. */
@@ -1,12 +1,14 @@
1
1
  import { migrationCommand } from "../changes/capture-schema.js";
2
2
  import { definitionsSchema, userTablesSchema } from "./names.js";
3
- import { fieldsSql, schemasSql, tablesSql, versionDefaultSql } from "./schema-sql.js";
3
+ import { fieldsOrganizationSql, fieldsSql, nameIndexesSql, organizationsSql, schemasSql, tablesSql, versionDefaultSql } from "./schema-sql.js";
4
4
  import { sql } from "kysely";
5
5
  //#region src/user-tables/schema.ts
6
+ const current = (found) => found.tables && found.versionDefault && found.organizations && found.fields && found.data && found.trgm;
6
7
  const readUserTablesSchema = async (db) => {
7
8
  const { rows } = await sql`select
8
9
  to_regclass(${`${definitionsSchema}.tables`}) is not null as tables,
9
10
  exists (select from information_schema.columns where table_schema = ${definitionsSchema} and table_name = 'tables' and column_name = 'version' and column_default is not null) as "versionDefault",
11
+ exists (select from information_schema.columns where table_schema = ${definitionsSchema} and table_name = 'tables' and column_name = 'table_name') as organizations,
10
12
  to_regclass(${`${definitionsSchema}.fields`}) is not null as fields,
11
13
  exists (select from pg_namespace where nspname = ${userTablesSchema}) as data,
12
14
  exists (select from pg_extension where extname = 'pg_trgm') as trgm`.execute(db);
@@ -14,22 +16,25 @@ const readUserTablesSchema = async (db) => {
14
16
  };
15
17
  /**
16
18
  * The SQL that adds what user tables need: pg_trgm, the definitions schema with its `tables` and `fields`, and the
17
- * schema the tables themselves live in. `undefined` when current. Nothing is dropped.
19
+ * schema the tables themselves live in. `undefined` when current. Nothing is dropped but the unique constraint on a
20
+ * definition's name, which a name unique per organization replaces.
18
21
  */
19
22
  const userTablesMigration = async (db) => {
20
23
  const found = await readUserTablesSchema(db);
21
- if (found.tables && found.versionDefault && found.fields && found.data && found.trgm) return void 0;
24
+ if (current(found)) return void 0;
25
+ const tables = found.tables ? [...found.versionDefault ? [] : [versionDefaultSql()], ...found.organizations ? [] : organizationsSql()] : [tablesSql(), ...nameIndexesSql()];
26
+ const fields = found.fields ? found.tables && !found.organizations ? [fieldsOrganizationSql()] : [] : [fieldsSql()];
22
27
  return `${[
23
28
  ...schemasSql(),
24
- ...found.tables ? found.versionDefault ? [] : [versionDefaultSql()] : [tablesSql()],
25
- ...found.fields ? [] : [fieldsSql()]
29
+ ...tables,
30
+ ...fields
26
31
  ].join(";\n\n")};`;
27
32
  };
28
33
  /** Refuses to go on while the schemas of user tables are missing; the project's migrations add them. */
29
34
  const checkUserTablesSchema = async (db) => {
30
35
  const found = await readUserTablesSchema(db);
31
- if (found.tables && found.versionDefault && found.fields && found.data && found.trgm) return;
32
- throw new Error(`The ${definitionsSchema} or ${userTablesSchema} schema, the default of a definition's version, or pg_trgm, is missing, and userTables keeps the tables users add there. Write it as a migration with \`${migrationCommand}\`, apply it with the project's migrations, then start again.`);
36
+ if (current(found)) return;
37
+ throw new Error(`The ${definitionsSchema} or ${userTablesSchema} schema, the default of a definition's version, a definition's organization, or pg_trgm, is missing, and userTables keeps the tables users add there. Write it as a migration with \`${migrationCommand}\`, apply it with the project's migrations, then start again.`);
33
38
  };
34
39
  //#endregion
35
40
  export { checkUserTablesSchema, userTablesMigration };
@@ -1,4 +1,4 @@
1
- import { nameProblem, userTablesSchema } from "./names.js";
1
+ import { nameProblem, organizationColumn, userTablesSchema } from "./names.js";
2
2
  import { sql } from "kysely";
3
3
  //#region src/user-tables/table-ddl.ts
4
4
  const checkedName = (name, kind) => {
@@ -41,6 +41,11 @@ const indexKind = (type) => {
41
41
  };
42
42
  /** The new table, with its key alone: its fields are added as columns. */
43
43
  const createTableSql = (table) => sql`create table ${tableId(table)} (id bigint generated always as identity primary key)`;
44
+ /**
45
+ * The new table of an organization: its key, and the organization of each record, which `.tenant` sets and every query
46
+ * filters by, with the index that filter reads.
47
+ */
48
+ const createOrganizationTableSql = (table) => [sql`create table ${tableId(table)} (id bigint generated always as identity primary key, ${sql.id(organizationColumn)} text not null)`, sql`create index on ${tableId(table)} (${sql.id(organizationColumn)})`];
44
49
  /** The field's column: nullable and without a default, so the records already there read it as empty. */
45
50
  const addColumnSql = (table, field) => sql`alter table ${tableId(table)} add column ${sql.id(checkedName(field.name, "field"))} ${columnType(field)}`;
46
51
  const btreeSql = (table, column) => sql`create index on ${tableId(table)} (${sql.id(checkedName(column, "field"))})`;
@@ -53,4 +58,4 @@ const fieldSql = (table, field) => {
53
58
  return [addColumnSql(table, field), ...index];
54
59
  };
55
60
  //#endregion
56
- export { addColumnSql, createTableSql, fieldSql, indexKind, trigramIndexSql };
61
+ export { addColumnSql, createOrganizationTableSql, createTableSql, fieldSql, indexKind, trigramIndexSql };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.67.4",
3
+ "version": "0.67.6",
4
4
  "description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -37,15 +37,16 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@better-auth/cimd": "^1.7.12",
40
+ "@better-auth/core": "^1.7.12",
40
41
  "@better-auth/mcp": "^1.7.12",
41
42
  "@better-auth/oauth-provider": "^1.7.12",
42
43
  "@better-auth/passkey": "^1.7.12",
43
44
  "@better-auth/sso": "^1.7.12",
44
45
  "@livekit/protocol": "1.53.1",
45
46
  "@modelcontextprotocol/server": "^2.3.1",
46
- "@protobase/layout": "0.67.4",
47
- "@protobase/query": "0.67.4",
48
- "@protobase/schema": "0.67.4",
47
+ "@protobase/layout": "0.67.6",
48
+ "@protobase/query": "0.67.6",
49
+ "@protobase/schema": "0.67.6",
49
50
  "@react-email/render": "^2.1.0",
50
51
  "@react-pdf/primitives": "^4.4.0",
51
52
  "@react-pdf/renderer": "^4.9.0",