@kontrolia/db 2.1.3 → 2.1.5

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/api-key.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  /** Generates a new plaintext application sync API key. Shown once, never stored. */
2
2
  export declare function generateApplicationApiKey(): string;
3
- /** sha256 hex digest — what actually gets stored in kontrolia_auth.applications.api_key_hash. */
3
+ /** sha256 hex digest — what actually gets stored as application_api_keys.key_hash. */
4
4
  export declare function hashApplicationApiKey(plaintext: string): string;
5
+ /** First few characters after the prefix, stored alongside the hash purely so a key list UI can show "kapp_a1b2c3…" without ever persisting (or being able to redisplay) the full secret. */
6
+ export declare function applicationApiKeyDisplayPrefix(plaintext: string): string;
5
7
  //# sourceMappingURL=api-key.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"api-key.d.ts","sourceRoot":"","sources":["../src/api-key.ts"],"names":[],"mappings":"AAIA,oFAAoF;AACpF,wBAAgB,yBAAyB,IAAI,MAAM,CAElD;AAED,iGAAiG;AACjG,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAE/D"}
1
+ {"version":3,"file":"api-key.d.ts","sourceRoot":"","sources":["../src/api-key.ts"],"names":[],"mappings":"AAIA,oFAAoF;AACpF,wBAAgB,yBAAyB,IAAI,MAAM,CAElD;AAED,sFAAsF;AACtF,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAE/D;AAED,6LAA6L;AAC7L,wBAAgB,8BAA8B,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAExE"}
package/dist/api-key.js CHANGED
@@ -4,7 +4,11 @@ const API_KEY_PREFIX = "kapp_";
4
4
  export function generateApplicationApiKey() {
5
5
  return `${API_KEY_PREFIX}${randomBytes(24).toString("base64url")}`;
6
6
  }
7
- /** sha256 hex digest — what actually gets stored in kontrolia_auth.applications.api_key_hash. */
7
+ /** sha256 hex digest — what actually gets stored as application_api_keys.key_hash. */
8
8
  export function hashApplicationApiKey(plaintext) {
9
9
  return createHash("sha256").update(plaintext).digest("hex");
10
10
  }
11
+ /** First few characters after the prefix, stored alongside the hash purely so a key list UI can show "kapp_a1b2c3…" without ever persisting (or being able to redisplay) the full secret. */
12
+ export function applicationApiKeyDisplayPrefix(plaintext) {
13
+ return plaintext.slice(0, API_KEY_PREFIX.length + 6);
14
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { migrate, type MigrateOptions } from "./migrate.js";
2
2
  export { grantPlatformAdmin, type GrantPlatformAdminOptions, type GrantPlatformAdminResult } from "./grant-platform-admin.js";
3
3
  export { registerApplication, type RegisterApplicationOptions, type RegisteredApplication, type PermissionInput, } from "./register-application.js";
4
- export { generateApplicationApiKey, hashApplicationApiKey } from "./api-key.js";
4
+ export { generateApplicationApiKey, hashApplicationApiKey, applicationApiKeyDisplayPrefix } from "./api-key.js";
5
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AAC5D,OAAO,EAAE,kBAAkB,EAAE,KAAK,yBAAyB,EAAE,KAAK,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAC9H,OAAO,EACL,mBAAmB,EACnB,KAAK,0BAA0B,EAC/B,KAAK,qBAAqB,EAC1B,KAAK,eAAe,GACrB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,yBAAyB,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AAC5D,OAAO,EAAE,kBAAkB,EAAE,KAAK,yBAAyB,EAAE,KAAK,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAC9H,OAAO,EACL,mBAAmB,EACnB,KAAK,0BAA0B,EAC/B,KAAK,qBAAqB,EAC1B,KAAK,eAAe,GACrB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,yBAAyB,EAAE,qBAAqB,EAAE,8BAA8B,EAAE,MAAM,cAAc,CAAC"}
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { migrate } from "./migrate.js";
2
2
  export { grantPlatformAdmin } from "./grant-platform-admin.js";
3
3
  export { registerApplication, } from "./register-application.js";
4
- export { generateApplicationApiKey, hashApplicationApiKey } from "./api-key.js";
4
+ export { generateApplicationApiKey, hashApplicationApiKey, applicationApiKeyDisplayPrefix } from "./api-key.js";
@@ -1 +1 @@
1
- {"version":3,"file":"migrate.d.ts","sourceRoot":"","sources":["../src/migrate.ts"],"names":[],"mappings":"AAQA,MAAM,WAAW,cAAc;IAC7B;;sDAEkD;IAClD,gBAAgB,EAAE,MAAM,CAAC;IACzB,8CAA8C;IAC9C,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAWD;;;GAGG;AACH,wBAAsB,OAAO,CAAC,EAAE,gBAAgB,EAAE,OAAe,EAAE,EAAE,cAAc,iBAgClF"}
1
+ {"version":3,"file":"migrate.d.ts","sourceRoot":"","sources":["../src/migrate.ts"],"names":[],"mappings":"AAQA,MAAM,WAAW,cAAc;IAC7B;;sDAEkD;IAClD,gBAAgB,EAAE,MAAM,CAAC;IACzB,8CAA8C;IAC9C,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAsCD;;;GAGG;AACH,wBAAsB,OAAO,CAAC,EAAE,gBAAgB,EAAE,OAAe,EAAE,EAAE,cAAc,iBAgClF"}
package/dist/migrate.js CHANGED
@@ -4,13 +4,34 @@ import { fileURLToPath } from "node:url";
4
4
  import { Client } from "pg";
5
5
  const __dirname = dirname(fileURLToPath(import.meta.url));
6
6
  const MIGRATIONS_DIR = join(__dirname, "..", "migrations");
7
+ /**
8
+ * Bootstraps (and self-heals the location of) the migration-tracking
9
+ * table, then returns its schema-qualified name for the rest of migrate()
10
+ * to use. On a genuinely fresh database neither `kontrolia` nor
11
+ * `kontrolia_auth` exists yet — migration 0001 is what creates the former,
12
+ * 0020 renames it to the latter — so this table has nowhere but `public`
13
+ * to start out in. Once `kontrolia_auth` exists (every subsequent run,
14
+ * fresh or not), it belongs there instead: an install that ran migrate()
15
+ * before this fix shipped may still have it sitting in `public`, so this
16
+ * relocates it once, idempotently, rather than leaving two copies around.
17
+ */
7
18
  async function ensureMigrationsTable(client) {
19
+ const { rows: schemaRows } = await client.query(`select exists (select 1 from information_schema.schemata where schema_name = 'kontrolia_auth') as exists`);
20
+ const kontroliaAuthExists = schemaRows[0]?.exists ?? false;
21
+ if (kontroliaAuthExists) {
22
+ const { rows: publicRows } = await client.query(`select exists (select 1 from information_schema.tables where table_schema = 'public' and table_name = 'kontrolia_migrations') as exists`);
23
+ if (publicRows[0]?.exists) {
24
+ await client.query("alter table public.kontrolia_migrations set schema kontrolia_auth");
25
+ }
26
+ }
27
+ const schema = kontroliaAuthExists ? "kontrolia_auth" : "public";
8
28
  await client.query(`
9
- create table if not exists kontrolia_migrations (
29
+ create table if not exists ${schema}.kontrolia_migrations (
10
30
  filename text primary key,
11
31
  applied_at timestamptz not null default now()
12
32
  )
13
33
  `);
34
+ return schema;
14
35
  }
15
36
  /**
16
37
  * Applies every .sql file under migrations/ that hasn't run yet, in
@@ -20,8 +41,8 @@ export async function migrate({ connectionString, verbose = false }) {
20
41
  const client = new Client({ connectionString });
21
42
  await client.connect();
22
43
  try {
23
- await ensureMigrationsTable(client);
24
- const { rows: applied } = await client.query("select filename from kontrolia_migrations");
44
+ const migrationsTable = `${await ensureMigrationsTable(client)}.kontrolia_migrations`;
45
+ const { rows: applied } = await client.query(`select filename from ${migrationsTable}`);
25
46
  const appliedSet = new Set(applied.map((r) => r.filename));
26
47
  const files = (await readdir(MIGRATIONS_DIR)).filter((f) => f.endsWith(".sql")).sort();
27
48
  for (const file of files) {
@@ -33,7 +54,7 @@ export async function migrate({ connectionString, verbose = false }) {
33
54
  await client.query("begin");
34
55
  try {
35
56
  await client.query(sql);
36
- await client.query("insert into kontrolia_migrations (filename) values ($1)", [file]);
57
+ await client.query(`insert into ${migrationsTable} (filename) values ($1)`, [file]);
37
58
  await client.query("commit");
38
59
  }
39
60
  catch (error) {
@@ -14,14 +14,6 @@ export interface RegisterApplicationOptions {
14
14
  export interface RegisteredApplication {
15
15
  applicationId: string;
16
16
  permissionKeys: string[];
17
- /**
18
- * The plaintext sync API key (see POST /api/applications/sync on
19
- * auth-server) — only present the first time this slug is registered.
20
- * Only the hash is stored; there is no way to recover it later, so the
21
- * caller must surface it to the operator immediately. `null` means the
22
- * application already existed and its key (if any) was left untouched.
23
- */
24
- apiKey: string | null;
25
17
  }
26
18
  /**
27
19
  * Inserts an application and its permission catalog directly against
@@ -30,7 +22,15 @@ export interface RegisteredApplication {
30
22
  * go through a platform-admin path), so this is that path: a direct,
31
23
  * service-role-equivalent write, the same way migrate() bypasses RLS to
32
24
  * apply schema changes. Safe to re-run — the slug and permission key are
33
- * both upserted, and re-running never rotates an existing api_key_hash.
25
+ * both upserted.
26
+ *
27
+ * Does NOT generate a sync API key — migration 0040 moved keys off
28
+ * `applications` onto the org-scoped `application_api_keys` table, and at
29
+ * the point this runs (typically during install, before any organization
30
+ * necessarily exists or has enabled the app) there's no organization to
31
+ * scope one to. Generate the first key afterward from admin-panel
32
+ * (Aplicaciones → tu app → API Keys), once at least one organization has
33
+ * enabled the application.
34
34
  */
35
35
  export declare function registerApplication(options: RegisterApplicationOptions): Promise<RegisteredApplication>;
36
36
  //# sourceMappingURL=register-application.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"register-application.d.ts","sourceRoot":"","sources":["../src/register-application.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,0BAA0B;IACzC,4DAA4D;IAC5D,gBAAgB,EAAE,MAAM,CAAC;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,aAAa,GAAG,SAAS,GAAG,YAAY,CAAC;IACtD,WAAW,EAAE,eAAe,EAAE,CAAC;CAChC;AAED,MAAM,WAAW,qBAAqB;IACpC,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB;;;;;;OAMG;IACH,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAED;;;;;;;;GAQG;AACH,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,0BAA0B,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAkC7G"}
1
+ {"version":3,"file":"register-application.d.ts","sourceRoot":"","sources":["../src/register-application.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,0BAA0B;IACzC,4DAA4D;IAC5D,gBAAgB,EAAE,MAAM,CAAC;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,aAAa,GAAG,SAAS,GAAG,YAAY,CAAC;IACtD,WAAW,EAAE,eAAe,EAAE,CAAC;CAChC;AAED,MAAM,WAAW,qBAAqB;IACpC,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,0BAA0B,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAgC7G"}
@@ -1,5 +1,4 @@
1
1
  import { Client } from "pg";
2
- import { generateApplicationApiKey, hashApplicationApiKey } from "./api-key.js";
3
2
  /**
4
3
  * Inserts an application and its permission catalog directly against
5
4
  * Postgres. kontrolia_auth.applications/permissions have no insert policy for
@@ -7,17 +6,24 @@ import { generateApplicationApiKey, hashApplicationApiKey } from "./api-key.js";
7
6
  * go through a platform-admin path), so this is that path: a direct,
8
7
  * service-role-equivalent write, the same way migrate() bypasses RLS to
9
8
  * apply schema changes. Safe to re-run — the slug and permission key are
10
- * both upserted, and re-running never rotates an existing api_key_hash.
9
+ * both upserted.
10
+ *
11
+ * Does NOT generate a sync API key — migration 0040 moved keys off
12
+ * `applications` onto the org-scoped `application_api_keys` table, and at
13
+ * the point this runs (typically during install, before any organization
14
+ * necessarily exists or has enabled the app) there's no organization to
15
+ * scope one to. Generate the first key afterward from admin-panel
16
+ * (Aplicaciones → tu app → API Keys), once at least one organization has
17
+ * enabled the application.
11
18
  */
12
19
  export async function registerApplication(options) {
13
20
  const client = new Client({ connectionString: options.connectionString });
14
21
  await client.connect();
15
22
  try {
16
- const candidateApiKey = generateApplicationApiKey();
17
- const { rows: [application], } = await client.query(`insert into kontrolia_auth.applications (name, slug, environment, api_key_hash)
18
- values ($1, $2, $3, $4)
23
+ const { rows: [application], } = await client.query(`insert into kontrolia_auth.applications (name, slug, environment)
24
+ values ($1, $2, $3)
19
25
  on conflict (slug) do update set name = excluded.name, environment = excluded.environment
20
- returning id, (xmax = 0) as inserted`, [options.name, options.slug, options.environment, hashApplicationApiKey(candidateApiKey)]);
26
+ returning id`, [options.name, options.slug, options.environment]);
21
27
  if (!application)
22
28
  throw new Error(`Failed to upsert application "${options.slug}"`);
23
29
  const permissionKeys = [];
@@ -28,7 +34,7 @@ export async function registerApplication(options) {
28
34
  on conflict (key) do update set description = excluded.description`, [application.id, permission.resource, permission.action, key, permission.description ?? null]);
29
35
  permissionKeys.push(key);
30
36
  }
31
- return { applicationId: application.id, permissionKeys, apiKey: application.inserted ? candidateApiKey : null };
37
+ return { applicationId: application.id, permissionKeys };
32
38
  }
33
39
  finally {
34
40
  await client.end();
@@ -0,0 +1,84 @@
1
+ -- 0013/0024 established "the database logs it, not application code" for
2
+ -- every other mutation surface, but roles and role_permissions were never
3
+ -- covered — creating or deleting a custom role, or granting/revoking a
4
+ -- permission on one, has produced zero audit trail since day one. Found
5
+ -- while designing API + MCP routes for role management: those routes must
6
+ -- not become the first and only place these actions get logged, since the
7
+ -- whole point of trigger-based auditing is that it keeps recording
8
+ -- regardless of which caller (browser, API, MCP, Studio/psql) made the
9
+ -- change.
10
+ --
11
+ -- These fire for system-role rows too (the auto-created "Administrador de
12
+ -- <app>" role, and its auto-synced permission set) — same as every other
13
+ -- trigger here, the log reflects what actually happened in the database,
14
+ -- not just human-initiated changes. actor_user_id is auth.uid(), which is
15
+ -- null for those system-triggered inserts; that's expected and matches how
16
+ -- 0013's own device.revoked/membership.created triggers already behave.
17
+
18
+ create or replace function kontrolia_auth.log_role_change()
19
+ returns trigger
20
+ language plpgsql
21
+ security definer
22
+ set search_path = ''
23
+ as $$
24
+ begin
25
+ if tg_op = 'INSERT' then
26
+ insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
27
+ values (
28
+ new.organization_id, auth.uid(), 'role.created', 'role', new.id::text,
29
+ jsonb_build_object('name', new.name, 'slug', new.slug, 'application_id', new.application_id, 'is_system_role', new.is_system_role)
30
+ );
31
+ return new;
32
+ else
33
+ if old.organization_id is null or exists (select 1 from kontrolia_auth.organizations where id = old.organization_id) then
34
+ insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
35
+ values (
36
+ old.organization_id, auth.uid(), 'role.deleted', 'role', old.id::text,
37
+ jsonb_build_object('name', old.name, 'slug', old.slug, 'application_id', old.application_id)
38
+ );
39
+ end if;
40
+ return old;
41
+ end if;
42
+ end;
43
+ $$;
44
+
45
+ create trigger audit_role_change
46
+ after insert or delete on kontrolia_auth.roles
47
+ for each row execute function kontrolia_auth.log_role_change();
48
+
49
+ create or replace function kontrolia_auth.log_role_permission_change()
50
+ returns trigger
51
+ language plpgsql
52
+ security definer
53
+ set search_path = ''
54
+ as $$
55
+ declare
56
+ target_role record;
57
+ begin
58
+ select organization_id, name, slug into target_role
59
+ from kontrolia_auth.roles
60
+ where id = coalesce(new.role_id, old.role_id);
61
+
62
+ if tg_op = 'INSERT' then
63
+ insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
64
+ values (
65
+ target_role.organization_id, auth.uid(), 'role_permission.granted', 'role', new.role_id::text,
66
+ jsonb_build_object('role_slug', target_role.slug, 'permission_id', new.permission_id)
67
+ );
68
+ return new;
69
+ else
70
+ if target_role.organization_id is null or exists (select 1 from kontrolia_auth.organizations where id = target_role.organization_id) then
71
+ insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
72
+ values (
73
+ target_role.organization_id, auth.uid(), 'role_permission.revoked', 'role', old.role_id::text,
74
+ jsonb_build_object('role_slug', target_role.slug, 'permission_id', old.permission_id)
75
+ );
76
+ end if;
77
+ return old;
78
+ end if;
79
+ end;
80
+ $$;
81
+
82
+ create trigger audit_role_permission_change
83
+ after insert or delete on kontrolia_auth.role_permissions
84
+ for each row execute function kontrolia_auth.log_role_permission_change();
@@ -0,0 +1,54 @@
1
+ -- Instance-wide branding/behavior settings for auth-server's hosted auth
2
+ -- screens (login, register, forgot-password, reset-password, verify-email,
3
+ -- MFA challenge, invitation accept, OAuth consent — everywhere AuthShell
4
+ -- renders). A single row, not per-organization: this installation's
5
+ -- self-hosted auth UI has one look, controlled by a platform admin from
6
+ -- admin-panel, same way there's one GoTrue project per installation.
7
+ --
8
+ -- registration_enabled: when false, auth-server hides "Crear cuenta" and
9
+ -- /register redirects back to /login — the only way to add a new person is
10
+ -- an Owner/Admin inviting them from admin-panel. theme forces light/dark
11
+ -- regardless of the browser's prefers-color-scheme, or 'system' (default)
12
+ -- to keep following it. button_color overrides --k-primary. logo_url
13
+ -- replaces the "K" mark + wordmark when set.
14
+ create table kontrolia_auth.instance_settings (
15
+ id boolean primary key default true,
16
+ registration_enabled boolean not null default true,
17
+ theme text not null default 'system' check (theme in ('light', 'dark', 'system')),
18
+ button_color text check (button_color is null or button_color ~ '^#[0-9a-fA-F]{6}$'),
19
+ logo_url text,
20
+ updated_at timestamptz not null default now(),
21
+ updated_by uuid references auth.users (id) on delete set null,
22
+ constraint instance_settings_singleton check (id)
23
+ );
24
+
25
+ comment on table kontrolia_auth.instance_settings is 'Singleton row (id is always true) — instance-wide branding/behavior for auth-server''s hosted auth screens.';
26
+
27
+ insert into kontrolia_auth.instance_settings (id) values (true);
28
+
29
+ alter table kontrolia_auth.instance_settings enable row level security;
30
+
31
+ -- Public read: auth-server's login/register/etc. pages render this before
32
+ -- anyone is authenticated, and none of these fields are sensitive.
33
+ create policy "anyone can read instance settings" on kontrolia_auth.instance_settings
34
+ for select using (true);
35
+
36
+ -- Writes still go through auth-server's API routes with a service-role
37
+ -- client (same Model B pattern as platform-admins/oauth-clients), which
38
+ -- bypasses RLS — this policy is defense-in-depth for any direct client use.
39
+ create policy "platform admins can update instance settings" on kontrolia_auth.instance_settings
40
+ for update using (exists (select 1 from kontrolia_auth.platform_admins pa where pa.user_id = auth.uid()));
41
+
42
+ grant select on kontrolia_auth.instance_settings to anon, authenticated;
43
+ grant update on kontrolia_auth.instance_settings to authenticated;
44
+
45
+ -- Logo uploads. Public bucket: served via Supabase's unauthenticated
46
+ -- /storage/v1/object/public/branding/... path, so auth-server's own
47
+ -- unauthenticated login screen can just <img src> it directly — no signed
48
+ -- URLs, no extra round-trip. Uploads/deletes only ever happen server-side
49
+ -- through auth-server's service-role client (same as every other
50
+ -- platform-admin-gated mutation in this codebase), so no storage.objects
51
+ -- RLS policies are needed for writes.
52
+ insert into storage.buckets (id, name, public)
53
+ values ('branding', 'branding', true)
54
+ on conflict (id) do nothing;
@@ -0,0 +1,171 @@
1
+ -- Replaces the single kapp_ key on kontrolia_auth.applications with a real
2
+ -- multi-key model: an application can have any number of named, revocable,
3
+ -- optionally-expiring keys, each independently scoped to whichever
4
+ -- organization it was generated for — not necessarily the application's
5
+ -- owner_organization_id. A tenant organization that merely enabled an
6
+ -- application (application_organizations) can mint its own key to call
7
+ -- that application's members API on its own behalf, without needing the
8
+ -- owning organization's involvement at all. The application's own global
9
+ -- permission catalog (POST /api/applications/sync) still accepts any valid,
10
+ -- non-revoked, non-expired key for the application regardless of which org
11
+ -- it's scoped to — syncing permissions isn't an org-scoped operation.
12
+ create table kontrolia_auth.application_api_keys (
13
+ id uuid primary key default gen_random_uuid(),
14
+ application_id uuid not null references kontrolia_auth.applications (id) on delete cascade,
15
+ organization_id uuid not null references kontrolia_auth.organizations (id) on delete cascade,
16
+ name text not null,
17
+ key_hash text not null,
18
+ -- First several characters of the plaintext, purely so the list UI can
19
+ -- show "kapp_a1b2c3…" to help an operator tell keys apart without ever
20
+ -- storing (or being able to show again) the full secret. Null for rows
21
+ -- backfilled from the old single-key column below, where no prefix was
22
+ -- ever recorded.
23
+ key_prefix text,
24
+ last_used_at timestamptz,
25
+ expires_at timestamptz,
26
+ revoked_at timestamptz,
27
+ revoked_by uuid references auth.users (id) on delete set null,
28
+ created_by uuid references auth.users (id) on delete set null,
29
+ created_at timestamptz not null default now()
30
+ );
31
+
32
+ comment on table kontrolia_auth.application_api_keys is 'Named, independently-scoped, revocable/expiring kapp_ keys — replaces the single applications.api_key_hash. organization_id is whichever org the key was generated for (must have application_id enabled), not necessarily the application''s owner.';
33
+
34
+ -- 0016's own "platform admins can view the platform admin list" SELECT
35
+ -- policy on platform_admins queries platform_admins from inside its own
36
+ -- policy — harmless as long as the only caller is service_role (which
37
+ -- bypasses RLS entirely, the only way this table has ever actually been
38
+ -- read in this codebase before now). The moment ANY other RLS policy
39
+ -- (this migration's, below) checks platform-admin status via a plain
40
+ -- `exists (select 1 from platform_admins ...)` under a real (non-service-role)
41
+ -- caller, Postgres has to evaluate platform_admins' own SELECT policy for
42
+ -- that subquery — which itself queries platform_admins — genuine infinite
43
+ -- recursion ("infinite recursion detected in policy for relation
44
+ -- platform_admins"), live-reproduced while building this migration's own
45
+ -- policies below. A SECURITY DEFINER function (same escape hatch
46
+ -- is_org_admin/is_org_owner already use for memberships/roles) runs with
47
+ -- the function owner's privileges, bypassing the caller's own RLS on the
48
+ -- table it queries — no recursion.
49
+ create or replace function kontrolia_auth.is_platform_admin()
50
+ returns boolean
51
+ language sql
52
+ stable
53
+ security definer
54
+ set search_path = ''
55
+ as $$
56
+ select exists (select 1 from kontrolia_auth.platform_admins pa where pa.user_id = auth.uid());
57
+ $$;
58
+
59
+ -- Retrofit 0016's policy onto the same function, closing the recursion risk
60
+ -- there too — not just in the new policies below that happened to expose it.
61
+ drop policy if exists "platform admins can view the platform admin list" on kontrolia_auth.platform_admins;
62
+ create policy "platform admins can view the platform admin list" on kontrolia_auth.platform_admins
63
+ for select using (kontrolia_auth.is_platform_admin());
64
+
65
+ alter table kontrolia_auth.application_api_keys enable row level security;
66
+
67
+ create policy "org admins can view their org's application keys" on kontrolia_auth.application_api_keys
68
+ for select using (kontrolia_auth.is_org_admin(organization_id) or kontrolia_auth.is_platform_admin());
69
+
70
+ create policy "org admins can create application keys for their org" on kontrolia_auth.application_api_keys
71
+ for insert
72
+ with check (
73
+ (kontrolia_auth.is_org_admin(organization_id) or kontrolia_auth.is_platform_admin())
74
+ and exists (
75
+ select 1 from kontrolia_auth.application_organizations ao
76
+ where ao.application_id = application_api_keys.application_id
77
+ and ao.organization_id = application_api_keys.organization_id
78
+ )
79
+ );
80
+
81
+ -- Keys are only ever revoked, never edited or hard-deleted — this policy
82
+ -- only needs to authorize the "set revoked_at/revoked_by" update, which is
83
+ -- everything the API route ever writes here.
84
+ create policy "org admins can revoke their org's application keys" on kontrolia_auth.application_api_keys
85
+ for update using (kontrolia_auth.is_org_admin(organization_id) or kontrolia_auth.is_platform_admin())
86
+ with check (kontrolia_auth.is_org_admin(organization_id) or kontrolia_auth.is_platform_admin());
87
+
88
+ -- Column-level ACL, same reasoning as 0032 for the column this replaces:
89
+ -- key_hash must never be readable by any authenticated user for any
90
+ -- organization. The auth-server route that matches a caller's Bearer key
91
+ -- against these rows runs as service_role, which needs its own explicit
92
+ -- grant — column privileges aren't inherited from a table-level grant.
93
+ grant select (
94
+ id, application_id, organization_id, name, key_prefix, last_used_at, expires_at, revoked_at, revoked_by, created_by, created_at
95
+ ) on kontrolia_auth.application_api_keys to authenticated;
96
+ grant insert (application_id, organization_id, name, key_hash, key_prefix, expires_at, created_by) on kontrolia_auth.application_api_keys to authenticated;
97
+ grant update (revoked_at, revoked_by) on kontrolia_auth.application_api_keys to authenticated;
98
+ grant select (id, application_id, organization_id, key_hash) on kontrolia_auth.application_api_keys to service_role;
99
+
100
+ -- Same "the database logs it" pattern as every other mutation surface
101
+ -- (0013, 0024, 0032, 0038).
102
+ create or replace function kontrolia_auth.log_application_api_key_lifecycle()
103
+ returns trigger
104
+ language plpgsql
105
+ security definer
106
+ set search_path = ''
107
+ as $$
108
+ begin
109
+ if tg_op = 'INSERT' then
110
+ insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
111
+ values (
112
+ new.organization_id, auth.uid(), 'application_api_key.created', 'application', new.application_id::text,
113
+ jsonb_build_object('keyId', new.id, 'name', new.name, 'expiresAt', new.expires_at)
114
+ );
115
+ return new;
116
+ end if;
117
+
118
+ if old.revoked_at is null and new.revoked_at is not null then
119
+ insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
120
+ values (
121
+ new.organization_id, auth.uid(), 'application_api_key.revoked', 'application', new.application_id::text,
122
+ jsonb_build_object('keyId', new.id, 'name', new.name)
123
+ );
124
+ end if;
125
+ return new;
126
+ end;
127
+ $$;
128
+
129
+ create trigger audit_application_api_key_lifecycle
130
+ after insert or update on kontrolia_auth.application_api_keys
131
+ for each row execute function kontrolia_auth.log_application_api_key_lifecycle();
132
+
133
+ -- Carry forward every application's existing single key as its first named
134
+ -- key, scoped to the same organization that already administered it
135
+ -- (owner_organization_id — the only organization the old RLS policy ever
136
+ -- let touch it).
137
+ insert into kontrolia_auth.application_api_keys (application_id, organization_id, name, key_hash, last_used_at, created_at)
138
+ select id, owner_organization_id, 'Clave original', api_key_hash, api_key_last_used_at, created_at
139
+ from kontrolia_auth.applications
140
+ where api_key_hash is not null and owner_organization_id is not null;
141
+
142
+ -- log_application_api_key_change() (0032, extended by 0034) did double duty:
143
+ -- logging api_key_hash changes AND ownership changes on kontrolia_auth.applications.
144
+ -- The key-lifecycle half moves to the new table's own trigger above; redefine
145
+ -- this one (same name, same trigger — a transparent swap, no drop/recreate
146
+ -- needed) to keep only what it's now solely responsible for.
147
+ create or replace function kontrolia_auth.log_application_api_key_change()
148
+ returns trigger
149
+ language plpgsql
150
+ security definer
151
+ set search_path = ''
152
+ as $$
153
+ begin
154
+ if old.owner_organization_id is distinct from new.owner_organization_id then
155
+ insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
156
+ values (
157
+ new.owner_organization_id,
158
+ auth.uid(),
159
+ case when old.owner_organization_id is null then 'application.ownership_claimed' else 'application.ownership_transferred' end,
160
+ 'application',
161
+ new.id::text,
162
+ jsonb_build_object('slug', new.slug, 'previous_owner_organization_id', old.owner_organization_id)
163
+ );
164
+ end if;
165
+
166
+ return new;
167
+ end;
168
+ $$;
169
+
170
+ alter table kontrolia_auth.applications drop column api_key_hash;
171
+ alter table kontrolia_auth.applications drop column api_key_last_used_at;
@@ -0,0 +1,14 @@
1
+ -- Tracks the single, auto-provisioned, non-deletable OAuth client reserved
2
+ -- for MCP agent connections (Claude Code, Claude Desktop, ChatGPT
3
+ -- connectors, ...). Registering a client OAuth was previously only
4
+ -- reachable through an application's own "Cliente OAuth" dialog — correct
5
+ -- for genuine app SSO, but a modeling mismatch for a generic agent that
6
+ -- isn't "logging in as" any one catalogued application. NULL until the
7
+ -- first visit to admin-panel's "Clientes OAuth" screen bootstraps it (see
8
+ -- POST /api/oauth-clients/mcp-bootstrap) — there's no way to create it from
9
+ -- a plain SQL migration, since GoTrue's OAuth clients live behind its own
10
+ -- admin HTTP API, not a table this schema can INSERT into directly.
11
+ alter table kontrolia_auth.instance_settings add column mcp_oauth_client_id text;
12
+
13
+ comment on column kontrolia_auth.instance_settings.mcp_oauth_client_id is
14
+ 'GoTrue client_id of the reserved MCP OAuth client, once bootstrapped. Not a secret (client_id is public in the OAuth flow) — safe to expose via the same public GET /api/instance-settings as everything else on this row.';
@@ -0,0 +1,32 @@
1
+ -- Stores the OAuth 2.1 tokens for KontrolIA Auth's own connection to
2
+ -- Supabase's Management API (a "Supabase OAuth App" a platform admin
3
+ -- authorizes once, from admin-panel's Social login screen) — the
4
+ -- self-renewing alternative to pasting a Personal Access Token that
5
+ -- silently expires. access_token is short-lived; refresh_token lets
6
+ -- auth-server renew it on its own, forever, until the connection is
7
+ -- revoked (from here, or from the user's own Supabase account).
8
+ --
9
+ -- Deliberately NOT modeled like instance_settings (public-read singleton):
10
+ -- RLS is enabled with zero policies, and there is no grant to anon/
11
+ -- authenticated at all beyond the schema's blanket default privileges from
12
+ -- migration 0008 — with RLS enabled and no permissive policy, that default
13
+ -- grant still resolves to zero rows/denied for every command, for every
14
+ -- role except service_role (which bypasses RLS entirely). This table's
15
+ -- contents are account-wide-privileged Supabase credentials, meant to be
16
+ -- reachable ONLY from auth-server's own service-role client — never via
17
+ -- PostgREST under any authenticated session, no matter how the caller got
18
+ -- there.
19
+ create table kontrolia_auth.supabase_oauth_connection (
20
+ id boolean primary key default true,
21
+ access_token text not null,
22
+ refresh_token text not null,
23
+ expires_at timestamptz not null,
24
+ connected_by uuid references auth.users (id) on delete set null,
25
+ connected_at timestamptz not null default now(),
26
+ updated_at timestamptz not null default now(),
27
+ constraint supabase_oauth_connection_singleton check (id)
28
+ );
29
+
30
+ comment on table kontrolia_auth.supabase_oauth_connection is 'Singleton row (id is always true) — OAuth tokens for this installation''s connection to Supabase''s Management API. service_role only, never exposed via PostgREST.';
31
+
32
+ alter table kontrolia_auth.supabase_oauth_connection enable row level security;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontrolia/db",
3
- "version": "2.1.3",
3
+ "version": "2.1.5",
4
4
  "license": "MIT",
5
5
  "description": "KontrolIA Auth database layer: SQL migrations for the `kontrolia` schema (organizations, RBAC, Custom Access Token Hook) and a connection-string-agnostic migration runner.",
6
6
  "keywords": [