@kontrolia/db 2.1.4 → 2.2.0

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.
@@ -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,15 @@
1
+ -- Tracks the last time an application's own deploy pipeline included this
2
+ -- permission in a POST /api/applications/sync call. Lets admin-panel flag
3
+ -- permissions an app has stopped declaring (its sibling permissions synced
4
+ -- more recently than this one did) so a platform admin can review and
5
+ -- prune them explicitly — sync itself never deletes, by design, since a
6
+ -- partial/broken deploy shouldn't silently revoke a permission a role
7
+ -- already grants.
8
+ --
9
+ -- Backfilled to now() rather than left null: every existing permission
10
+ -- predates this column, and leaving them null would make the entire
11
+ -- pre-existing catalog look "never synced" the moment this migration
12
+ -- lands, even though most of it is actively in use. now() means nothing
13
+ -- looks stale until an app's sync pattern actually diverges after today.
14
+ alter table kontrolia_auth.permissions
15
+ add column last_synced_at timestamptz not null default now();
@@ -0,0 +1,524 @@
1
+ -- Plans, subscriptions and usage limits — the "entitlements" layer.
2
+ --
3
+ -- A plan belongs to an application and describes a commercial tier: what it
4
+ -- unlocks (plan_permissions — a subset of the app's own permission catalog)
5
+ -- and how much of it can be consumed (plan_limits — numeric quotas per
6
+ -- period). A subscription ties ONE organization to ONE plan of ONE
7
+ -- application. Organizations, not users, subscribe: the tenant is what pays,
8
+ -- a person can belong to several tenants with different roles, and per-seat
9
+ -- pricing is just a limit ("members" = 5) rather than per-user subscriptions.
10
+ --
11
+ -- Effective access = role permissions ∩ plan permissions. The access-token
12
+ -- hook below applies that intersection, but only for applications that opt
13
+ -- in with plans_required = true — every existing application keeps working
14
+ -- exactly as before until an operator flips that flag.
15
+ --
16
+ -- Payments themselves never live here: KontrolIA Auth brokers Stripe
17
+ -- (Checkout + Customer Portal + webhooks) and only stores the resulting
18
+ -- state and the provider's ids. Subscriptions can also be assigned
19
+ -- manually (free tiers, trials, enterprise customers billed by invoice).
20
+
21
+ alter table kontrolia_auth.applications
22
+ add column plans_required boolean not null default false,
23
+ add column past_due_grace_days integer not null default 7 check (past_due_grace_days >= 0);
24
+
25
+ comment on column kontrolia_auth.applications.plans_required is
26
+ 'When true, a member only keeps the permissions of this application that the organization''s live subscription plan includes. False = plans are informational only, access is governed by roles alone.';
27
+ comment on column kontrolia_auth.applications.past_due_grace_days is
28
+ 'Days after a failed renewal (status past_due) during which the organization keeps access before the subscription stops counting as live.';
29
+
30
+ -- ---------------------------------------------------------------------------
31
+ -- Plans
32
+ -- ---------------------------------------------------------------------------
33
+
34
+ create table kontrolia_auth.plans (
35
+ id uuid primary key default gen_random_uuid(),
36
+ application_id uuid not null references kontrolia_auth.applications (id) on delete cascade,
37
+ slug text not null check (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'),
38
+ name text not null,
39
+ description text,
40
+ -- Minor units (centavos/cents), never floats.
41
+ price_amount integer not null default 0 check (price_amount >= 0),
42
+ currency text not null default 'MXN' check (currency ~ '^[A-Z]{3}$'),
43
+ billing_interval text not null default 'month' check (billing_interval in ('month', 'year', 'one_time')),
44
+ trial_days integer not null default 0 check (trial_days >= 0),
45
+ -- Free-form marketing bullets ("Hasta 5 usuarios", "Soporte prioritario").
46
+ features jsonb not null default '[]'::jsonb check (jsonb_typeof(features) = 'array'),
47
+ is_active boolean not null default true,
48
+ -- Assigned automatically (as a manual subscription) when an organization
49
+ -- enables the application — the "free tier". At most one per application.
50
+ is_default boolean not null default false,
51
+ sort_order integer not null default 0,
52
+ stripe_product_id text,
53
+ stripe_price_id text,
54
+ created_at timestamptz not null default now(),
55
+ updated_at timestamptz not null default now(),
56
+ unique (application_id, slug)
57
+ );
58
+
59
+ create unique index plans_one_default_per_application on kontrolia_auth.plans (application_id) where is_default;
60
+ create index plans_application_id_idx on kontrolia_auth.plans (application_id);
61
+
62
+ comment on table kontrolia_auth.plans is 'Commercial tiers of an application (Startup / Pro / ...). What each unlocks lives in plan_permissions; quotas in plan_limits.';
63
+
64
+ create table kontrolia_auth.plan_permissions (
65
+ plan_id uuid not null references kontrolia_auth.plans (id) on delete cascade,
66
+ permission_id uuid not null references kontrolia_auth.permissions (id) on delete cascade,
67
+ primary key (plan_id, permission_id)
68
+ );
69
+
70
+ comment on table kontrolia_auth.plan_permissions is 'Which of the application''s permissions a plan unlocks. Effective access for a member = role permissions ∩ this set (when the app has plans_required).';
71
+
72
+ -- A plan can only reference permissions of its own application.
73
+ create or replace function kontrolia_auth.enforce_plan_permission_same_application()
74
+ returns trigger
75
+ language plpgsql
76
+ security definer
77
+ set search_path = ''
78
+ as $$
79
+ declare
80
+ plan_app uuid;
81
+ permission_app uuid;
82
+ begin
83
+ select application_id into plan_app from kontrolia_auth.plans where id = new.plan_id;
84
+ select application_id into permission_app from kontrolia_auth.permissions where id = new.permission_id;
85
+ if plan_app is distinct from permission_app then
86
+ raise exception 'El permiso % no pertenece a la aplicación del plan %', new.permission_id, new.plan_id
87
+ using errcode = 'check_violation';
88
+ end if;
89
+ return new;
90
+ end;
91
+ $$;
92
+
93
+ create trigger plan_permissions_same_application
94
+ before insert or update on kontrolia_auth.plan_permissions
95
+ for each row execute function kontrolia_auth.enforce_plan_permission_same_application();
96
+
97
+ create table kontrolia_auth.plan_limits (
98
+ plan_id uuid not null references kontrolia_auth.plans (id) on delete cascade,
99
+ -- Namespaced by the application itself, e.g. "facturas.emitidas", "miembros".
100
+ limit_key text not null check (limit_key ~ '^[a-z0-9_.-]+$'),
101
+ -- NULL = unlimited for this plan (still counted, never exceeded).
102
+ limit_value integer check (limit_value is null or limit_value >= 0),
103
+ period text not null default 'month' check (period in ('day', 'month', 'year', 'lifetime')),
104
+ description text,
105
+ primary key (plan_id, limit_key)
106
+ );
107
+
108
+ comment on table kontrolia_auth.plan_limits is 'Numeric quotas of a plan ("up to 100 invoices per month"). NULL limit_value = unlimited.';
109
+
110
+ -- ---------------------------------------------------------------------------
111
+ -- Subscriptions
112
+ -- ---------------------------------------------------------------------------
113
+
114
+ create table kontrolia_auth.subscriptions (
115
+ id uuid primary key default gen_random_uuid(),
116
+ organization_id uuid not null references kontrolia_auth.organizations (id) on delete cascade,
117
+ application_id uuid not null references kontrolia_auth.applications (id) on delete cascade,
118
+ -- restrict: a plan with subscriptions can't be deleted, only deactivated.
119
+ plan_id uuid not null references kontrolia_auth.plans (id) on delete restrict,
120
+ status text not null check (status in ('trialing', 'active', 'past_due', 'canceled', 'expired')),
121
+ current_period_start timestamptz,
122
+ -- NULL = open-ended (manual subscriptions, one-time purchases).
123
+ current_period_end timestamptz,
124
+ cancel_at_period_end boolean not null default false,
125
+ provider text not null default 'manual' check (provider in ('manual', 'stripe')),
126
+ provider_customer_id text,
127
+ provider_subscription_id text,
128
+ created_by uuid,
129
+ created_at timestamptz not null default now(),
130
+ updated_at timestamptz not null default now()
131
+ );
132
+
133
+ -- One live subscription per (organization, application). Canceled/expired
134
+ -- rows stay as history.
135
+ create unique index subscriptions_one_live_per_org_app on kontrolia_auth.subscriptions (organization_id, application_id)
136
+ where status in ('trialing', 'active', 'past_due');
137
+ create unique index subscriptions_provider_subscription_id on kontrolia_auth.subscriptions (provider, provider_subscription_id)
138
+ where provider_subscription_id is not null;
139
+ create index subscriptions_org_app_idx on kontrolia_auth.subscriptions (organization_id, application_id);
140
+
141
+ comment on table kontrolia_auth.subscriptions is 'An organization''s subscription to one plan of one application. Provider-managed (Stripe) or manual.';
142
+
143
+ -- Single definition of "does this subscription currently grant access",
144
+ -- shared by the token hook, the entitlements API and RLS. past_due keeps
145
+ -- access for the application's grace window, measured from the end of the
146
+ -- period whose renewal failed.
147
+ create or replace function kontrolia_auth.subscription_is_live(s kontrolia_auth.subscriptions, grace_days integer)
148
+ returns boolean
149
+ language sql
150
+ stable
151
+ set search_path = ''
152
+ as $$
153
+ select case
154
+ when s.status in ('trialing', 'active') then (s.current_period_end is null or s.current_period_end > now())
155
+ when s.status = 'past_due' then now() < coalesce(s.current_period_end, s.updated_at) + make_interval(days => coalesce(grace_days, 0))
156
+ else false
157
+ end
158
+ $$;
159
+
160
+ -- The live subscription of an organization for an application, if any.
161
+ create or replace function kontrolia_auth.live_subscription(org_id uuid, app_id uuid)
162
+ returns kontrolia_auth.subscriptions
163
+ language sql
164
+ stable
165
+ security definer
166
+ set search_path = ''
167
+ as $$
168
+ select s.*
169
+ from kontrolia_auth.subscriptions s
170
+ join kontrolia_auth.applications a on a.id = s.application_id
171
+ where s.organization_id = org_id
172
+ and s.application_id = app_id
173
+ and s.status in ('trialing', 'active', 'past_due')
174
+ and kontrolia_auth.subscription_is_live(s, a.past_due_grace_days)
175
+ limit 1
176
+ $$;
177
+
178
+ -- Enabling an application for an organization automatically subscribes it
179
+ -- to the application's default plan (the free tier), so plans_required apps
180
+ -- work from the first login instead of starting locked out. A trigger, not
181
+ -- route code, because admin-panel enables apps with a direct client insert.
182
+ create or replace function kontrolia_auth.subscribe_default_plan_on_enable()
183
+ returns trigger
184
+ language plpgsql
185
+ security definer
186
+ set search_path = ''
187
+ as $$
188
+ declare
189
+ default_plan uuid;
190
+ begin
191
+ select id into default_plan
192
+ from kontrolia_auth.plans
193
+ where application_id = new.application_id and is_default and is_active;
194
+
195
+ if default_plan is not null and not exists (
196
+ select 1 from kontrolia_auth.subscriptions
197
+ where organization_id = new.organization_id
198
+ and application_id = new.application_id
199
+ and status in ('trialing', 'active', 'past_due')
200
+ ) then
201
+ insert into kontrolia_auth.subscriptions (organization_id, application_id, plan_id, status, provider)
202
+ values (new.organization_id, new.application_id, default_plan, 'active', 'manual');
203
+ end if;
204
+ return new;
205
+ end;
206
+ $$;
207
+
208
+ create trigger application_organizations_subscribe_default_plan
209
+ after insert on kontrolia_auth.application_organizations
210
+ for each row execute function kontrolia_auth.subscribe_default_plan_on_enable();
211
+
212
+ create table kontrolia_auth.subscription_events (
213
+ id uuid primary key default gen_random_uuid(),
214
+ subscription_id uuid references kontrolia_auth.subscriptions (id) on delete set null,
215
+ organization_id uuid references kontrolia_auth.organizations (id) on delete set null,
216
+ application_id uuid references kontrolia_auth.applications (id) on delete set null,
217
+ type text not null,
218
+ provider text not null default 'manual',
219
+ -- Stripe event id; the unique index is what makes webhook delivery
220
+ -- idempotent (a redelivered event inserts nothing and is skipped).
221
+ provider_event_id text,
222
+ payload jsonb,
223
+ created_at timestamptz not null default now()
224
+ );
225
+
226
+ -- Plain (not partial) unique index: NULLs are distinct, so manual events
227
+ -- without a provider id never collide, and PostgREST upserts with
228
+ -- ignoreDuplicates can target it (partial indexes can't be an ON CONFLICT
229
+ -- target without their predicate).
230
+ create unique index subscription_events_provider_event_id on kontrolia_auth.subscription_events (provider, provider_event_id);
231
+ create index subscription_events_org_app_idx on kontrolia_auth.subscription_events (organization_id, application_id, created_at desc);
232
+
233
+ -- ---------------------------------------------------------------------------
234
+ -- Usage
235
+ -- ---------------------------------------------------------------------------
236
+
237
+ create table kontrolia_auth.usage_counters (
238
+ organization_id uuid not null references kontrolia_auth.organizations (id) on delete cascade,
239
+ application_id uuid not null references kontrolia_auth.applications (id) on delete cascade,
240
+ limit_key text not null,
241
+ -- First day of the counting window; '1970-01-01' for lifetime limits.
242
+ period_start date not null,
243
+ used integer not null default 0 check (used >= 0),
244
+ updated_at timestamptz not null default now(),
245
+ primary key (organization_id, application_id, limit_key, period_start)
246
+ );
247
+
248
+ create table kontrolia_auth.usage_events (
249
+ id uuid primary key default gen_random_uuid(),
250
+ organization_id uuid not null references kontrolia_auth.organizations (id) on delete cascade,
251
+ application_id uuid not null references kontrolia_auth.applications (id) on delete cascade,
252
+ limit_key text not null,
253
+ amount integer not null,
254
+ idempotency_key text,
255
+ created_at timestamptz not null default now()
256
+ );
257
+
258
+ create unique index usage_events_idempotency on kontrolia_auth.usage_events (application_id, idempotency_key);
259
+
260
+ create or replace function kontrolia_auth.usage_period_start(period text, moment timestamptz default now())
261
+ returns date
262
+ language sql
263
+ stable
264
+ set search_path = ''
265
+ as $$
266
+ select case period
267
+ when 'day' then (moment at time zone 'UTC')::date
268
+ when 'month' then date_trunc('month', moment at time zone 'UTC')::date
269
+ when 'year' then date_trunc('year', moment at time zone 'UTC')::date
270
+ else date '1970-01-01'
271
+ end
272
+ $$;
273
+
274
+ -- Atomic usage report. Resolves the organization's live plan for the app,
275
+ -- finds the limit (NULL limit = unlimited, still counted under a monthly
276
+ -- window so it can be reported), increments the counter in one statement,
277
+ -- and returns the resulting state. Idempotent per (application,
278
+ -- idempotency_key): a retried report returns the current state without
279
+ -- counting twice. Never blocks on its own — `exceeded` is information for
280
+ -- the application to act on.
281
+ create or replace function kontrolia_auth.record_usage(
282
+ org_id uuid,
283
+ app_id uuid,
284
+ key text,
285
+ amount integer default 1,
286
+ idempotency text default null
287
+ )
288
+ returns table (used integer, limit_value integer, period text, period_start date, exceeded boolean, plan_slug text)
289
+ language plpgsql
290
+ security definer
291
+ set search_path = ''
292
+ as $$
293
+ -- The OUT columns share names with usage_counters columns; inside SQL
294
+ -- statements (notably the ON CONFLICT target) the column must win.
295
+ #variable_conflict use_column
296
+ declare
297
+ live kontrolia_auth.subscriptions;
298
+ resolved_period text := 'month';
299
+ resolved_limit integer := null;
300
+ resolved_plan_slug text := null;
301
+ window_start date;
302
+ already_recorded boolean := false;
303
+ new_used integer;
304
+ begin
305
+ if amount < 0 then
306
+ raise exception 'amount debe ser >= 0' using errcode = 'check_violation';
307
+ end if;
308
+
309
+ live := kontrolia_auth.live_subscription(org_id, app_id);
310
+ if live.id is not null then
311
+ select pl.period, pl.limit_value, p.slug
312
+ into resolved_period, resolved_limit, resolved_plan_slug
313
+ from kontrolia_auth.plans p
314
+ left join kontrolia_auth.plan_limits pl on pl.plan_id = p.id and pl.limit_key = key
315
+ where p.id = live.plan_id;
316
+ resolved_period := coalesce(resolved_period, 'month');
317
+ end if;
318
+
319
+ window_start := kontrolia_auth.usage_period_start(resolved_period);
320
+
321
+ if idempotency is not null then
322
+ insert into kontrolia_auth.usage_events (organization_id, application_id, limit_key, amount, idempotency_key)
323
+ values (org_id, app_id, key, amount, idempotency)
324
+ on conflict (application_id, idempotency_key) do nothing;
325
+ already_recorded := not found;
326
+ else
327
+ insert into kontrolia_auth.usage_events (organization_id, application_id, limit_key, amount)
328
+ values (org_id, app_id, key, amount);
329
+ end if;
330
+
331
+ if already_recorded or amount = 0 then
332
+ select c.used into new_used
333
+ from kontrolia_auth.usage_counters c
334
+ where c.organization_id = org_id and c.application_id = app_id and c.limit_key = key and c.period_start = window_start;
335
+ new_used := coalesce(new_used, 0);
336
+ else
337
+ insert into kontrolia_auth.usage_counters as c (organization_id, application_id, limit_key, period_start, used)
338
+ values (org_id, app_id, key, window_start, amount)
339
+ on conflict (organization_id, application_id, limit_key, period_start)
340
+ do update set used = c.used + excluded.used, updated_at = now()
341
+ returning c.used into new_used;
342
+ end if;
343
+
344
+ return query select
345
+ new_used,
346
+ resolved_limit,
347
+ resolved_period,
348
+ window_start,
349
+ (resolved_limit is not null and new_used > resolved_limit),
350
+ resolved_plan_slug;
351
+ end;
352
+ $$;
353
+
354
+ -- ---------------------------------------------------------------------------
355
+ -- RLS
356
+ -- ---------------------------------------------------------------------------
357
+
358
+ alter table kontrolia_auth.plans enable row level security;
359
+ alter table kontrolia_auth.plan_permissions enable row level security;
360
+ alter table kontrolia_auth.plan_limits enable row level security;
361
+ alter table kontrolia_auth.subscriptions enable row level security;
362
+ alter table kontrolia_auth.subscription_events enable row level security;
363
+ alter table kontrolia_auth.usage_counters enable row level security;
364
+ alter table kontrolia_auth.usage_events enable row level security;
365
+
366
+ -- Pricing is public by nature (an app shows it to prospects) — same breadth
367
+ -- as the application catalog itself. All writes go through auth-server's
368
+ -- service-role routes (platform admin), never from a client session.
369
+ create policy "authenticated users can browse plans" on kontrolia_auth.plans
370
+ for select to authenticated using (true);
371
+ create policy "authenticated users can browse plan permissions" on kontrolia_auth.plan_permissions
372
+ for select to authenticated using (true);
373
+ create policy "authenticated users can browse plan limits" on kontrolia_auth.plan_limits
374
+ for select to authenticated using (true);
375
+
376
+ create policy "org members can view their subscriptions" on kontrolia_auth.subscriptions
377
+ for select to authenticated
378
+ using (kontrolia_auth.is_org_member(organization_id) or kontrolia_auth.is_platform_admin());
379
+
380
+ create policy "org admins can view their subscription events" on kontrolia_auth.subscription_events
381
+ for select to authenticated
382
+ using ((organization_id is not null and kontrolia_auth.is_org_admin(organization_id)) or kontrolia_auth.is_platform_admin());
383
+
384
+ create policy "org members can view their usage" on kontrolia_auth.usage_counters
385
+ for select to authenticated
386
+ using (kontrolia_auth.is_org_member(organization_id) or kontrolia_auth.is_platform_admin());
387
+
388
+ create policy "org admins can view their usage events" on kontrolia_auth.usage_events
389
+ for select to authenticated
390
+ using (kontrolia_auth.is_org_admin(organization_id) or kontrolia_auth.is_platform_admin());
391
+
392
+ -- Migration 0008's default privileges already grant authenticated/service_role
393
+ -- table access (RLS above is what actually scopes it); the token hook runs
394
+ -- as supabase_auth_admin and needs its own grants on everything it reads.
395
+ grant select on kontrolia_auth.applications to supabase_auth_admin;
396
+ grant select on kontrolia_auth.plans to supabase_auth_admin;
397
+ grant select on kontrolia_auth.plan_permissions to supabase_auth_admin;
398
+ grant select on kontrolia_auth.subscriptions to supabase_auth_admin;
399
+
400
+ -- ---------------------------------------------------------------------------
401
+ -- Token hook: intersect with the live plan, expose the plans claim
402
+ -- ---------------------------------------------------------------------------
403
+
404
+ create or replace function kontrolia_auth.custom_access_token_hook(event jsonb)
405
+ returns jsonb
406
+ language plpgsql
407
+ stable
408
+ security definer
409
+ set search_path = ''
410
+ as $$
411
+ declare
412
+ claims jsonb;
413
+ target_user_id uuid;
414
+ active_org_id uuid;
415
+ active_membership_id uuid;
416
+ role_names text[];
417
+ permission_keys text[];
418
+ platform_admin boolean;
419
+ plan_claims jsonb;
420
+ begin
421
+ claims := coalesce(event->'claims', '{}'::jsonb);
422
+ target_user_id := (event->>'user_id')::uuid;
423
+
424
+ select active_organization_id into active_org_id
425
+ from kontrolia_auth.sessions_context
426
+ where user_id = target_user_id;
427
+
428
+ if active_org_id is null then
429
+ select organization_id into active_org_id
430
+ from kontrolia_auth.memberships
431
+ where user_id = target_user_id and status = 'active'
432
+ order by created_at asc
433
+ limit 1;
434
+ end if;
435
+
436
+ if active_org_id is not null then
437
+ select id into active_membership_id
438
+ from kontrolia_auth.memberships
439
+ where user_id = target_user_id
440
+ and organization_id = active_org_id
441
+ and status = 'active';
442
+ end if;
443
+
444
+ if active_membership_id is not null then
445
+ select coalesce(array_agg(distinct r.slug), '{}')
446
+ into role_names
447
+ from kontrolia_auth.membership_roles mr
448
+ join kontrolia_auth.roles r on r.id = mr.role_id
449
+ where mr.membership_id = active_membership_id and r.is_system_role;
450
+
451
+ select coalesce(array_agg(distinct p.key), '{}')
452
+ into permission_keys
453
+ from (
454
+ select p.id, p.key
455
+ from kontrolia_auth.membership_roles mr
456
+ join kontrolia_auth.role_permissions rp on rp.role_id = mr.role_id
457
+ join kontrolia_auth.permissions p on p.id = rp.permission_id
458
+ where mr.membership_id = active_membership_id
459
+ union
460
+ select p.id, p.key
461
+ from kontrolia_auth.user_permissions up
462
+ join kontrolia_auth.permissions p on p.id = up.permission_id
463
+ where up.membership_id = active_membership_id and up.effect = 'allow'
464
+ ) p
465
+ where not exists (
466
+ select 1 from kontrolia_auth.user_permissions up_deny
467
+ where up_deny.membership_id = active_membership_id
468
+ and up_deny.permission_id = p.id
469
+ and up_deny.effect = 'deny'
470
+ );
471
+
472
+ -- Plan gate: for applications that require a plan, keep only the
473
+ -- permissions the organization's live subscription plan includes.
474
+ -- Applications without plans_required pass through untouched.
475
+ select coalesce(array_agg(pk), '{}')
476
+ into permission_keys
477
+ from unnest(permission_keys) pk
478
+ join kontrolia_auth.permissions p on p.key = pk
479
+ join kontrolia_auth.applications a on a.id = p.application_id
480
+ where not a.plans_required
481
+ or exists (
482
+ select 1
483
+ from kontrolia_auth.subscriptions s
484
+ join kontrolia_auth.plan_permissions pp on pp.plan_id = s.plan_id and pp.permission_id = p.id
485
+ where s.organization_id = active_org_id
486
+ and s.application_id = a.id
487
+ and s.status in ('trialing', 'active', 'past_due')
488
+ and kontrolia_auth.subscription_is_live(s, a.past_due_grace_days)
489
+ );
490
+
491
+ -- { "<app slug>": "<plan slug>" } for every live subscription of the
492
+ -- active organization — enough for an app to show "Plan Pro" without a
493
+ -- round trip; limits and usage are deliberately NOT here (they change
494
+ -- with every use — see GET /api/entitlements).
495
+ select coalesce(jsonb_object_agg(a.slug, p.slug), '{}'::jsonb)
496
+ into plan_claims
497
+ from kontrolia_auth.subscriptions s
498
+ join kontrolia_auth.applications a on a.id = s.application_id
499
+ join kontrolia_auth.plans p on p.id = s.plan_id
500
+ where s.organization_id = active_org_id
501
+ and s.status in ('trialing', 'active', 'past_due')
502
+ and kontrolia_auth.subscription_is_live(s, a.past_due_grace_days);
503
+ else
504
+ role_names := '{}';
505
+ permission_keys := '{}';
506
+ plan_claims := '{}'::jsonb;
507
+ end if;
508
+
509
+ select exists(select 1 from kontrolia_auth.platform_admins where user_id = target_user_id) into platform_admin;
510
+
511
+ claims := jsonb_set(claims, '{organization_id}', coalesce(to_jsonb(active_org_id), 'null'::jsonb));
512
+ claims := jsonb_set(claims, '{roles}', to_jsonb(coalesce(role_names, '{}')));
513
+ claims := jsonb_set(claims, '{permissions}', to_jsonb(coalesce(permission_keys, '{}')));
514
+ claims := jsonb_set(claims, '{plans}', coalesce(plan_claims, '{}'::jsonb));
515
+ claims := jsonb_set(claims, '{is_platform_admin}', to_jsonb(coalesce(platform_admin, false)));
516
+
517
+ event := jsonb_set(event, '{claims}', claims);
518
+ return event;
519
+ end;
520
+ $$;
521
+
522
+ grant execute on function kontrolia_auth.subscription_is_live(kontrolia_auth.subscriptions, integer) to supabase_auth_admin, authenticated, service_role;
523
+ grant execute on function kontrolia_auth.live_subscription(uuid, uuid) to authenticated, service_role;
524
+ grant execute on function kontrolia_auth.record_usage(uuid, uuid, text, integer, text) to service_role;
@@ -0,0 +1,8 @@
1
+ -- 0032 replaced authenticated's table-level SELECT on applications with an
2
+ -- explicit column list (to hide api_key_hash), and 0037 had to add
3
+ -- oauth_client_id to it for the same reason: any column added afterwards
4
+ -- is invisible to authenticated until it's granted explicitly, and
5
+ -- Postgres reports that as "permission denied for table applications"
6
+ -- on any query that names it — which is what PATCH /api/applications/[id]
7
+ -- and GET /api/applications hit after 0044 added these two.
8
+ grant select (plans_required, past_due_grace_days) on kontrolia_auth.applications to authenticated;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontrolia/db",
3
- "version": "2.1.4",
3
+ "version": "2.2.0",
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": [