diff --git a/packages/app-scope/__tests__/actor-entity-type.test.ts b/packages/app-scope/__tests__/actor-entity-type.test.ts new file mode 100644 index 000000000..e35556495 --- /dev/null +++ b/packages/app-scope/__tests__/actor-entity-type.test.ts @@ -0,0 +1,264 @@ +import { getConnections, PgTestClient } from 'pgsql-test'; + +let pg: PgTestClient; +let teardown: () => Promise; + +const DATABASE_ID = '22222222-2222-2222-2222-222222222222'; +const EMPTY_DATABASE_ID = '33333333-3333-3333-3333-333333333333'; +const PRINCIPAL_DATABASE_ID = '77777777-7777-7777-7777-777777777777'; +const APP_USER_ID = '44444444-4444-4444-4444-444444444444'; +const ORG_USER_ID = '55555555-5555-5555-5555-555555555555'; +const UNKNOWN_USER_ID = '66666666-6666-6666-6666-666666666666'; +const PRINCIPAL_OWNER_ID = '88888888-8888-8888-8888-888888888888'; +const PRINCIPAL_USER_ID = '99999999-9999-9999-9999-999999999999'; +const ORPHAN_PRINCIPAL_USER_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; +const NESTED_OWNER_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; +const NESTED_PRINCIPAL_USER_ID = 'cccccccc-cccc-cccc-cccc-cccccccccccc'; + +describe('app_scope.actor_entity', () => { + beforeAll(async () => { + ({ pg, teardown } = await getConnections()); + + await pg.query( + `INSERT INTO metaschema_public.database (id, name) + VALUES + ($1, 'actor_entity_type_db'), + ($2, 'actor_entity_type_empty_db'), + ($3, 'actor_entity_type_principal_db')`, + [DATABASE_ID, EMPTY_DATABASE_ID, PRINCIPAL_DATABASE_ID] + ); + await pg.query(`CREATE SCHEMA actor_entity_type_test`); + await pg.query(`CREATE TABLE actor_entity_type_test.role_types ( + id integer PRIMARY KEY, + name text NOT NULL + )`); + await pg.query(`CREATE TABLE actor_entity_type_test.users ( + id uuid PRIMARY KEY, + type integer NOT NULL + )`); + await pg.query( + `INSERT INTO actor_entity_type_test.role_types (id, name) + VALUES (1, 'User'), (2, 'Organization'), (3, 'Principal')` + ); + await pg.query( + `INSERT INTO actor_entity_type_test.users (id, type) + VALUES ($1, 1), ($2, 2)`, + [APP_USER_ID, ORG_USER_ID] + ); + + const schema = await pg.one<{ id: string }>( + `INSERT INTO metaschema_public.schema (database_id, name, schema_name) + VALUES ($1, 'actor_entity_type_test', 'actor_entity_type_test') + RETURNING id`, + [DATABASE_ID] + ); + const users = await pg.one<{ id: string }>( + `INSERT INTO metaschema_public."table" (database_id, schema_id, name) + VALUES ($1, $2, 'users') + RETURNING id`, + [DATABASE_ID, schema.id] + ); + const roleTypes = await pg.one<{ id: string }>( + `INSERT INTO metaschema_public."table" (database_id, schema_id, name) + VALUES ($1, $2, 'role_types') + RETURNING id`, + [DATABASE_ID, schema.id] + ); + await pg.query( + `INSERT INTO metaschema_modules_public.users_module + (database_id, schema_id, table_id, type_table_id) + VALUES ($1, $2, $3, $4)`, + [DATABASE_ID, schema.id, users.id, roleTypes.id] + ); + + await pg.query(`CREATE SCHEMA actor_entity_principal_test`); + await pg.query(`CREATE TABLE actor_entity_principal_test.role_types ( + id integer PRIMARY KEY, + name text NOT NULL + )`); + await pg.query(`CREATE TABLE actor_entity_principal_test.users ( + id uuid PRIMARY KEY, + type integer NOT NULL + )`); + await pg.query(`CREATE TABLE actor_entity_principal_test.principals ( + user_id uuid PRIMARY KEY, + owner_id uuid NOT NULL + )`); + await pg.query(`CREATE TABLE actor_entity_principal_test.principal_entities ( + user_id uuid NOT NULL, + entity_id uuid NOT NULL + )`); + await pg.query(`CREATE TABLE actor_entity_principal_test.sessions ( + id uuid PRIMARY KEY + )`); + await pg.query(`CREATE TABLE actor_entity_principal_test.session_credentials ( + id uuid PRIMARY KEY + )`); + await pg.query( + `INSERT INTO actor_entity_principal_test.role_types (id, name) + VALUES (1, 'User'), (2, 'Organization'), (3, 'Principal')` + ); + await pg.query( + `INSERT INTO actor_entity_principal_test.users (id, type) + VALUES + ($1, 1), + ($2, 3), + ($3, 3), + ($4, 3), + ($5, 3)`, + [ + PRINCIPAL_OWNER_ID, + PRINCIPAL_USER_ID, + ORPHAN_PRINCIPAL_USER_ID, + NESTED_OWNER_ID, + NESTED_PRINCIPAL_USER_ID, + ] + ); + await pg.query( + `INSERT INTO actor_entity_principal_test.principals (user_id, owner_id) + VALUES ($1, $2), ($3, $4)`, + [PRINCIPAL_USER_ID, PRINCIPAL_OWNER_ID, NESTED_PRINCIPAL_USER_ID, NESTED_OWNER_ID] + ); + + const principalSchema = await pg.one<{ id: string }>( + `INSERT INTO metaschema_public.schema (database_id, name, schema_name) + VALUES ($1, 'actor_entity_principal_test', 'actor_entity_principal_test') + RETURNING id`, + [PRINCIPAL_DATABASE_ID] + ); + const principalUsers = await pg.one<{ id: string }>( + `INSERT INTO metaschema_public."table" (database_id, schema_id, name) + VALUES ($1, $2, 'users') + RETURNING id`, + [PRINCIPAL_DATABASE_ID, principalSchema.id] + ); + const principalRoleTypes = await pg.one<{ id: string }>( + `INSERT INTO metaschema_public."table" (database_id, schema_id, name) + VALUES ($1, $2, 'role_types') + RETURNING id`, + [PRINCIPAL_DATABASE_ID, principalSchema.id] + ); + const principalPrincipals = await pg.one<{ id: string }>( + `INSERT INTO metaschema_public."table" (database_id, schema_id, name) + VALUES ($1, $2, 'principals') + RETURNING id`, + [PRINCIPAL_DATABASE_ID, principalSchema.id] + ); + const principalEntities = await pg.one<{ id: string }>( + `INSERT INTO metaschema_public."table" (database_id, schema_id, name) + VALUES ($1, $2, 'principal_entities') + RETURNING id`, + [PRINCIPAL_DATABASE_ID, principalSchema.id] + ); + const principalSessions = await pg.one<{ id: string }>( + `INSERT INTO metaschema_public."table" (database_id, schema_id, name) + VALUES ($1, $2, 'sessions') + RETURNING id`, + [PRINCIPAL_DATABASE_ID, principalSchema.id] + ); + const principalSessionCredentials = await pg.one<{ id: string }>( + `INSERT INTO metaschema_public."table" (database_id, schema_id, name) + VALUES ($1, $2, 'session_credentials') + RETURNING id`, + [PRINCIPAL_DATABASE_ID, principalSchema.id] + ); + await pg.query( + `INSERT INTO metaschema_modules_public.users_module + (database_id, schema_id, table_id, type_table_id) + VALUES ($1, $2, $3, $4)`, + [ + PRINCIPAL_DATABASE_ID, + principalSchema.id, + principalUsers.id, + principalRoleTypes.id, + ] + ); + await pg.query( + `INSERT INTO metaschema_modules_public.principal_auth_module + ( + database_id, + schema_id, + principals_table_id, + principal_entities_table_id, + users_table_id, + sessions_table_id, + session_credentials_table_id + ) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + PRINCIPAL_DATABASE_ID, + principalSchema.id, + principalPrincipals.id, + principalEntities.id, + principalUsers.id, + principalSessions.id, + principalSessionCredentials.id, + ] + ); + }); + + afterAll(() => teardown()); + + it('resolves an ordinary user from the users role-types registry', async () => { + const actual = await pg.one<{ entity_id: string; entity_type: string }>( + `SELECT * FROM app_scope.actor_entity($1, $2)`, + [DATABASE_ID, APP_USER_ID] + ); + expect(actual).toEqual({ entity_id: APP_USER_ID, entity_type: 'app' }); + }); + + it('resolves an organization-typed users row to the org scope', async () => { + const actual = await pg.one<{ entity_id: string; entity_type: string }>( + `SELECT * FROM app_scope.actor_entity($1, $2)`, + [DATABASE_ID, ORG_USER_ID] + ); + expect(actual).toEqual({ entity_id: ORG_USER_ID, entity_type: 'org' }); + }); + + it('raises for an unknown actor', async () => { + await expect( + pg.one(`SELECT * FROM app_scope.actor_entity($1, $2)`, [DATABASE_ID, UNKNOWN_USER_ID]) + ).rejects.toMatchObject({ code: '42704' }); + }); + + it('resolves in a database that installs users without principal_auth (minimal preset)', async () => { + // Minimal is the preset the sync gateway serves; the previous implementation 500'd on it. + const actual = await pg.one<{ entity_id: string; entity_type: string }>( + `SELECT * FROM app_scope.actor_entity($1, $2)`, + [DATABASE_ID, APP_USER_ID] + ); + expect(actual).toEqual({ entity_id: APP_USER_ID, entity_type: 'app' }); + }); + + it('resolves a principal to its type-1 owner in a database with principal_auth', async () => { + const actual = await pg.one<{ entity_id: string; entity_type: string }>( + `SELECT * FROM app_scope.actor_entity($1, $2)`, + [PRINCIPAL_DATABASE_ID, PRINCIPAL_USER_ID] + ); + expect(actual).toEqual({ entity_id: PRINCIPAL_OWNER_ID, entity_type: 'app' }); + }); + + it('raises when a principal has no principals row', async () => { + await expect( + pg.one(`SELECT * FROM app_scope.actor_entity($1, $2)`, [ + PRINCIPAL_DATABASE_ID, + ORPHAN_PRINCIPAL_USER_ID, + ]) + ).rejects.toMatchObject({ code: '42704' }); + }); + + it('raises when a principal owner is itself a principal', async () => { + await expect( + pg.one(`SELECT * FROM app_scope.actor_entity($1, $2)`, [ + PRINCIPAL_DATABASE_ID, + NESTED_PRINCIPAL_USER_ID, + ]) + ).rejects.toMatchObject({ code: '42704' }); + }); + + it('raises when the database has no users module', async () => { + await expect( + pg.one(`SELECT * FROM app_scope.actor_entity($1, $2)`, [EMPTY_DATABASE_ID, UNKNOWN_USER_ID]) + ).rejects.toMatchObject({ code: '42704' }); + }); +}); diff --git a/packages/app-scope/deploy/schemas/app_scope/procedures/actor_entity.sql b/packages/app-scope/deploy/schemas/app_scope/procedures/actor_entity.sql new file mode 100644 index 000000000..40ed9d7e8 --- /dev/null +++ b/packages/app-scope/deploy/schemas/app_scope/procedures/actor_entity.sql @@ -0,0 +1,139 @@ +-- Deploy schemas/app_scope/procedures/actor_entity to pg +-- requires: schemas/app_scope/schema +-- requires: metaschema-schema:schemas/metaschema_public/tables/table/table +-- requires: metaschema-modules:schemas/metaschema_modules_public/tables/users_module/table +-- requires: metaschema-modules:schemas/metaschema_modules_public/tables/principal_auth_module/table + +BEGIN; + +-- actor_entity: the entity pair an actor carries when the actor IS the entity. +-- +-- A request that names no entity anywhere (no route, parameter or header +-- entity) and is not actorless is done on behalf of the actor itself. A +-- principal is not an entity of its own: work authenticated by a credential is +-- done on behalf of the human who owns it, so a principal actor resolves to its +-- owner's pair. +-- +-- users.type is an FK into the users module's own role-types table, which +-- metaschema_generators.users_module seeds with the same three rows in every +-- database (1 User, 2 Organization, 3 Principal) and exposes no grant, policy +-- or insert action for — so the type is a closed set and the scope it maps to +-- is our constant, not tenant data. Reading it back out of a per-database table +-- would only be indirection. The seed is pinned by a test that fails if it ever +-- stops matching this mapping. +-- +-- Raises rather than returning NULL: the caller is stamping the claims a +-- transaction will be attributed by, and half a pair is what strict attribution +-- exists to refuse. A NULL here would surface much later as an unattributable +-- job, with no trace of which request minted it. +-- +-- Dynamic SELECT against dynamically-named tables (identifiers are data, values +-- are bound parameters) because metaschema-generated tables live under each +-- database's schema hash — the same portable idiom as app_scope.dyn_lookup_uuid +-- and app_scope.membership_parent's probe, with no AST/deparser dependency. +CREATE FUNCTION app_scope.actor_entity( + database_id uuid, + actor_id uuid +) RETURNS TABLE ( + entity_id uuid, + entity_type text +) AS $$ +DECLARE + users_schema text; + users_table text; + principals_schema text; + principals_table text; + + -- the actor's own row, and the owner row a principal actor resolves to + actor_type integer; + owner_id uuid; + owner_type integer; + + resolved_id uuid; + resolved_type integer; + resolved_scope text; +BEGIN + IF actor_entity.actor_id IS NULL THEN + RAISE EXCEPTION 'ACTOR_REQUIRED: actor_entity needs an actor to type' + USING ERRCODE = '22004'; + END IF; + + -- The principals table is LEFT JOINed: a database may install users without + -- principal_auth (the minimal preset does), and that only matters if the + -- actor turns out to be a principal. + SELECT users_schema_row.schema_name, users_table_row.name, + principals_schema_row.schema_name, principals_table_row.name + INTO users_schema, users_table, principals_schema, principals_table + FROM metaschema_modules_public.users_module um + JOIN metaschema_public."table" users_table_row + ON (users_table_row.id = um.table_id) + JOIN metaschema_public.schema users_schema_row + ON (users_schema_row.id = users_table_row.schema_id + AND users_schema_row.database_id = users_table_row.database_id) + LEFT JOIN metaschema_modules_public.principal_auth_module pam + ON (pam.database_id = um.database_id) + LEFT JOIN metaschema_public."table" principals_table_row + ON (principals_table_row.id = pam.principals_table_id) + LEFT JOIN metaschema_public.schema principals_schema_row + ON (principals_schema_row.id = principals_table_row.schema_id + AND principals_schema_row.database_id = principals_table_row.database_id) + WHERE um.database_id = actor_entity.database_id; + + IF users_schema IS NULL THEN + RAISE EXCEPTION 'ACTOR_ENTITY_UNRESOLVED: database % installs no users module, so an actor has no entity to carry', actor_entity.database_id + USING ERRCODE = '42704'; + END IF; + + IF principals_schema IS NULL THEN + -- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: the generated users table is dynamically named, while actor_id stays a bound parameter + EXECUTE format( + 'SELECT u.type FROM %I.%I u WHERE u.id = $1', + users_schema, users_table + ) INTO actor_type USING actor_entity.actor_id; + ELSE + -- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: the generated users and principals tables are dynamically named, while actor_id stays a bound parameter + EXECUTE format( + 'SELECT u.type, owner.id, owner.type' + ' FROM %I.%I u' + ' LEFT JOIN %I.%I p ON p.user_id = u.id' + ' LEFT JOIN %I.%I owner ON owner.id = p.owner_id' + ' WHERE u.id = $1', + users_schema, users_table, + principals_schema, principals_table, + users_schema, users_table + ) INTO actor_type, owner_id, owner_type USING actor_entity.actor_id; + END IF; + + IF actor_type IS NULL THEN + RAISE EXCEPTION 'ACTOR_ENTITY_UNRESOLVED: actor % has no users row in database %', actor_entity.actor_id, actor_entity.database_id + USING ERRCODE = '42704'; + END IF; + + IF actor_type = 3 THEN + resolved_id := owner_id; + resolved_type := owner_type; + ELSE + resolved_id := actor_entity.actor_id; + resolved_type := actor_type; + END IF; + + resolved_scope := CASE resolved_type + WHEN 1 THEN 'app' + WHEN 2 THEN 'org' + END; + + -- Unresolvable: an unknown users.type, a principal with no owner row, or a + -- principal owned by another principal. + IF resolved_id IS NULL OR resolved_scope IS NULL THEN + RAISE EXCEPTION 'ACTOR_ENTITY_UNRESOLVED: actor % of users.type % resolves to no entity in database %', actor_entity.actor_id, actor_type, actor_entity.database_id + USING ERRCODE = '42704'; + END IF; + + RETURN QUERY SELECT resolved_id, resolved_scope; +END; +$$ LANGUAGE plpgsql STABLE SECURITY DEFINER; + +COMMENT ON FUNCTION app_scope.actor_entity(uuid, uuid) IS +'Resolves an actor to a complete entity pair from its users.type, or to the owner pair for a principal. Raises ACTOR_ENTITY_UNRESOLVED rather than returning a partial pair.'; + +COMMIT; diff --git a/packages/app-scope/deploy/schemas/app_scope/procedures/frame_owns.sql b/packages/app-scope/deploy/schemas/app_scope/procedures/frame_owns.sql new file mode 100644 index 000000000..fbb0ceb5b --- /dev/null +++ b/packages/app-scope/deploy/schemas/app_scope/procedures/frame_owns.sql @@ -0,0 +1,91 @@ +-- Deploy schemas/app_scope/procedures/frame_owns to pg +-- requires: schemas/app_scope/schema +-- requires: schemas/app_scope/procedures/frames + +BEGIN; + +-- frame_owns: does an execution's frame chain contain the frame that owns a +-- given catalog row? One boolean over app_scope.frames, so a WRITE-path +-- ownership check can admit exactly what the READ path would have resolved. +-- +-- A typed catalog row carries its owning frame as a triple: +-- (owner_scope, owner_key, owner_database_id) +-- written by catalog sync — a database-scope row is ('database', ), +-- a global-tier row is ('app' | 'platform', NULL) — and the row's home database +-- disambiguates the one physical relation that holds every database's rows. +-- This function answers whether that triple names one of the frames +-- app_scope.frames returns for the execution, using the SAME identity test +-- function_resolution.resolve probes the catalog with: +-- +-- keyed frame: owner_scope = f.scope AND owner_key = f.key_value +-- AND owner_database_id = (owner_key at `database` scope, +-- f.lookup_database_id otherwise) +-- global frame: owner_scope = f.scope AND owner_key IS NULL +-- AND owner_database_id = f.lookup_database_id +-- +-- The chain is an ANCESTRY relation, never a visibility one: it runs from the +-- execution's own scope up through its database, then the platform database's +-- own chain to the global `platform` terminal. A peer — another tenant, another +-- keyed owner — is not on it and can never be admitted, which is why the +-- ownership question is expressed as "is this on my chain", not "may I see +-- this". Publication and visibility flags are not inputs here, exactly as they +-- are not inputs to the same-owner rule this arm sits beside. +-- +-- No scope name is hardcoded, so a hierarchy that grows new levels needs no +-- change here: whatever app_scope.frames walks is what this admits. +CREATE FUNCTION app_scope.frame_owns( + database_id uuid, + scope text, + entity_id uuid, + owner_scope text, + owner_key uuid, + owner_database_id uuid +) RETURNS boolean AS $$ +DECLARE + v_owns boolean; +BEGIN + -- A row whose owning frame is not fully identified cannot be proved to be + -- on the chain, and an ownership check reports "not owned" rather than + -- guessing. The execution's own database is equally required: the frame walk + -- has no starting point without it. + IF frame_owns.database_id IS NULL + OR frame_owns.scope IS NULL + OR frame_owns.owner_scope IS NULL + OR frame_owns.owner_database_id IS NULL THEN + RETURN false; + END IF; + + SELECT EXISTS ( + SELECT 1 + FROM app_scope.frames( + frame_owns.database_id, + frame_owns.scope, + frame_owns.entity_id + ) AS f(scope, lookup_database_id, key_value) + WHERE f.scope = frame_owns.owner_scope + AND ( + ( + frame_owns.owner_key IS NOT NULL + AND f.key_value = frame_owns.owner_key + AND frame_owns.owner_database_id = CASE + WHEN frame_owns.owner_scope = 'database' + THEN frame_owns.owner_key + ELSE f.lookup_database_id + END + ) + OR ( + frame_owns.owner_key IS NULL + AND frame_owns.owner_database_id = f.lookup_database_id + ) + ) + ) + INTO v_owns; + + RETURN v_owns; +END; +$$ LANGUAGE plpgsql STABLE SECURITY DEFINER; + +COMMENT ON FUNCTION app_scope.frame_owns(uuid, text, uuid, text, uuid, uuid) IS +'True when the frame that owns a catalog row — its (owner_scope, owner_key, owner_database_id) triple — is one of the frames app_scope.frames returns for the given execution. The identity test is the one function_resolution.resolve probes the catalog with, so a write-path ownership guard admits exactly what the read path would resolve. The chain is ancestry, not visibility: the execution''s own scopes, its database, then the platform database''s chain — never a peer or another keyed owner. Returns false rather than raising when the row''s owning frame is not fully identified.'; + +COMMIT; diff --git a/packages/app-scope/pgpm.plan b/packages/app-scope/pgpm.plan index 580ac0498..ac11665a9 100644 --- a/packages/app-scope/pgpm.plan +++ b/packages/app-scope/pgpm.plan @@ -7,6 +7,8 @@ schemas/app_scope/procedures/platform_database_id [schemas/app_scope/schema] 201 schemas/app_scope/procedures/dyn_lookup_uuid [schemas/app_scope/schema] 2017-08-11T08:11:51Z constructive # dyn lookup uuid schemas/app_scope/procedures/projected_parent [schemas/app_scope/schema] 2026-08-05T09:00:00Z devin # scope type projection parent lookup schemas/app_scope/procedures/membership_parent [schemas/app_scope/schema schemas/app_scope/procedures/projected_parent] 2017-08-11T08:11:51Z constructive # membership parent +schemas/app_scope/procedures/actor_entity [schemas/app_scope/schema metaschema-modules:schemas/metaschema_modules_public/tables/principal_auth_module/table metaschema-modules:schemas/metaschema_modules_public/tables/users_module/table] 2026-08-27T09:00:00Z devin # actor entity pair lookup schemas/app_scope/procedures/local_frames [schemas/app_scope/schema schemas/app_scope/procedures/dyn_lookup_uuid schemas/app_scope/procedures/membership_parent] 2017-08-11T08:11:51Z constructive # per-database local frames schemas/app_scope/procedures/frames [schemas/app_scope/schema schemas/app_scope/procedures/platform_database_id schemas/app_scope/procedures/local_frames] 2017-08-11T08:11:51Z constructive # ordered scope frames schemas/app_scope/procedures/routing_tables [schemas/app_scope/schema schemas/app_scope/procedures/frames] 2026-07-26T09:00:00Z devin # scoped routing/site/domain/route/app table name resolution +schemas/app_scope/procedures/frame_owns [schemas/app_scope/schema schemas/app_scope/procedures/frames] 2026-08-26T09:00:00Z devin # frame-chain ownership test for write-path guards diff --git a/packages/app-scope/revert/schemas/app_scope/procedures/actor_entity.sql b/packages/app-scope/revert/schemas/app_scope/procedures/actor_entity.sql new file mode 100644 index 000000000..3e9802849 --- /dev/null +++ b/packages/app-scope/revert/schemas/app_scope/procedures/actor_entity.sql @@ -0,0 +1,7 @@ +-- Revert schemas/app_scope/procedures/actor_entity from pg + +BEGIN; + +DROP FUNCTION app_scope.actor_entity(uuid, uuid); + +COMMIT; diff --git a/packages/app-scope/revert/schemas/app_scope/procedures/frame_owns.sql b/packages/app-scope/revert/schemas/app_scope/procedures/frame_owns.sql new file mode 100644 index 000000000..c3aa4ea76 --- /dev/null +++ b/packages/app-scope/revert/schemas/app_scope/procedures/frame_owns.sql @@ -0,0 +1,7 @@ +-- Revert schemas/app_scope/procedures/frame_owns from pg + +BEGIN; + +DROP FUNCTION app_scope.frame_owns(uuid, text, uuid, text, uuid, uuid); + +COMMIT; diff --git a/packages/app-scope/sql/pgpm-app-scope--0.43.2.bundle.tar.gz b/packages/app-scope/sql/pgpm-app-scope--0.43.2.bundle.tar.gz index 4247c0243..5caa24d69 100644 Binary files a/packages/app-scope/sql/pgpm-app-scope--0.43.2.bundle.tar.gz and b/packages/app-scope/sql/pgpm-app-scope--0.43.2.bundle.tar.gz differ diff --git a/packages/app-scope/sql/pgpm-app-scope--0.43.2.sql b/packages/app-scope/sql/pgpm-app-scope--0.43.2.sql index d7ccc7d18..80caa713f 100644 --- a/packages/app-scope/sql/pgpm-app-scope--0.43.2.sql +++ b/packages/app-scope/sql/pgpm-app-scope--0.43.2.sql @@ -175,6 +175,110 @@ BEGIN END; $EOFCODE$ LANGUAGE plpgsql STABLE SECURITY DEFINER; +CREATE FUNCTION app_scope.actor_entity( + database_id uuid, + actor_id uuid +) RETURNS TABLE ( + entity_id uuid, + entity_type text +) AS $EOFCODE$ +DECLARE + users_schema text; + users_table text; + principals_schema text; + principals_table text; + + -- the actor's own row, and the owner row a principal actor resolves to + actor_type integer; + owner_id uuid; + owner_type integer; + + resolved_id uuid; + resolved_type integer; + resolved_scope text; +BEGIN + IF actor_entity.actor_id IS NULL THEN + RAISE EXCEPTION 'ACTOR_REQUIRED: actor_entity needs an actor to type' + USING ERRCODE = '22004'; + END IF; + + -- The principals table is LEFT JOINed: a database may install users without + -- principal_auth (the minimal preset does), and that only matters if the + -- actor turns out to be a principal. + SELECT users_schema_row.schema_name, users_table_row.name, + principals_schema_row.schema_name, principals_table_row.name + INTO users_schema, users_table, principals_schema, principals_table + FROM metaschema_modules_public.users_module um + JOIN metaschema_public."table" users_table_row + ON (users_table_row.id = um.table_id) + JOIN metaschema_public.schema users_schema_row + ON (users_schema_row.id = users_table_row.schema_id + AND users_schema_row.database_id = users_table_row.database_id) + LEFT JOIN metaschema_modules_public.principal_auth_module pam + ON (pam.database_id = um.database_id) + LEFT JOIN metaschema_public."table" principals_table_row + ON (principals_table_row.id = pam.principals_table_id) + LEFT JOIN metaschema_public.schema principals_schema_row + ON (principals_schema_row.id = principals_table_row.schema_id + AND principals_schema_row.database_id = principals_table_row.database_id) + WHERE um.database_id = actor_entity.database_id; + + IF users_schema IS NULL THEN + RAISE EXCEPTION 'ACTOR_ENTITY_UNRESOLVED: database % installs no users module, so an actor has no entity to carry', actor_entity.database_id + USING ERRCODE = '42704'; + END IF; + + IF principals_schema IS NULL THEN + -- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: the generated users table is dynamically named, while actor_id stays a bound parameter + EXECUTE format( + 'SELECT u.type FROM %I.%I u WHERE u.id = $1', + users_schema, users_table + ) INTO actor_type USING actor_entity.actor_id; + ELSE + -- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: the generated users and principals tables are dynamically named, while actor_id stays a bound parameter + EXECUTE format( + 'SELECT u.type, owner.id, owner.type' + ' FROM %I.%I u' + ' LEFT JOIN %I.%I p ON p.user_id = u.id' + ' LEFT JOIN %I.%I owner ON owner.id = p.owner_id' + ' WHERE u.id = $1', + users_schema, users_table, + principals_schema, principals_table, + users_schema, users_table + ) INTO actor_type, owner_id, owner_type USING actor_entity.actor_id; + END IF; + + IF actor_type IS NULL THEN + RAISE EXCEPTION 'ACTOR_ENTITY_UNRESOLVED: actor % has no users row in database %', actor_entity.actor_id, actor_entity.database_id + USING ERRCODE = '42704'; + END IF; + + IF actor_type = 3 THEN + resolved_id := owner_id; + resolved_type := owner_type; + ELSE + resolved_id := actor_entity.actor_id; + resolved_type := actor_type; + END IF; + + resolved_scope := CASE resolved_type + WHEN 1 THEN 'app' + WHEN 2 THEN 'org' + END; + + -- Unresolvable: an unknown users.type, a principal with no owner row, or a + -- principal owned by another principal. + IF resolved_id IS NULL OR resolved_scope IS NULL THEN + RAISE EXCEPTION 'ACTOR_ENTITY_UNRESOLVED: actor % of users.type % resolves to no entity in database %', actor_entity.actor_id, actor_type, actor_entity.database_id + USING ERRCODE = '42704'; + END IF; + + RETURN QUERY SELECT resolved_id, resolved_scope; +END; +$EOFCODE$ LANGUAGE plpgsql STABLE SECURITY DEFINER; + +COMMENT ON FUNCTION app_scope.actor_entity(uuid, uuid) IS 'Resolves an actor to a complete entity pair from its users.type, or to the owner pair for a principal. Raises ACTOR_ENTITY_UNRESOLVED rather than returning a partial pair.'; + CREATE FUNCTION app_scope.local_frames( database_id uuid, execution_scope text, @@ -601,4 +705,58 @@ BEGIN END; $EOFCODE$ LANGUAGE plpgsql STABLE; -COMMENT ON FUNCTION app_scope.routing_tables(uuid, text) IS 'Physical (schema, table) names of the scoped routing source/settings tables serving an execution at the given scope, resolved from the api_surface_module, site_surface_module, domain_module, route_module and app_module registrations. Scope resolution one layer above app_scope.frames: each surface is located independently by walking frames (exact-scope frames first, then most-specific first), so an inner frame''s apis surface never hijacks the domain/site/route planes served by an outer frame. No platform special-casing. Surfaces not provisioned on any frame come back NULL; a registration pointing at a missing table raises ROUTING_TABLES_NOT_FOUND.'; \ No newline at end of file +COMMENT ON FUNCTION app_scope.routing_tables(uuid, text) IS 'Physical (schema, table) names of the scoped routing source/settings tables serving an execution at the given scope, resolved from the api_surface_module, site_surface_module, domain_module, route_module and app_module registrations. Scope resolution one layer above app_scope.frames: each surface is located independently by walking frames (exact-scope frames first, then most-specific first), so an inner frame''s apis surface never hijacks the domain/site/route planes served by an outer frame. No platform special-casing. Surfaces not provisioned on any frame come back NULL; a registration pointing at a missing table raises ROUTING_TABLES_NOT_FOUND.'; + +CREATE FUNCTION app_scope.frame_owns( + database_id uuid, + scope text, + entity_id uuid, + owner_scope text, + owner_key uuid, + owner_database_id uuid +) RETURNS boolean AS $EOFCODE$ +DECLARE + v_owns boolean; +BEGIN + -- A row whose owning frame is not fully identified cannot be proved to be + -- on the chain, and an ownership check reports "not owned" rather than + -- guessing. The execution's own database is equally required: the frame walk + -- has no starting point without it. + IF frame_owns.database_id IS NULL + OR frame_owns.scope IS NULL + OR frame_owns.owner_scope IS NULL + OR frame_owns.owner_database_id IS NULL THEN + RETURN false; + END IF; + + SELECT EXISTS ( + SELECT 1 + FROM app_scope.frames( + frame_owns.database_id, + frame_owns.scope, + frame_owns.entity_id + ) AS f(scope, lookup_database_id, key_value) + WHERE f.scope = frame_owns.owner_scope + AND ( + ( + frame_owns.owner_key IS NOT NULL + AND f.key_value = frame_owns.owner_key + AND frame_owns.owner_database_id = CASE + WHEN frame_owns.owner_scope = 'database' + THEN frame_owns.owner_key + ELSE f.lookup_database_id + END + ) + OR ( + frame_owns.owner_key IS NULL + AND frame_owns.owner_database_id = f.lookup_database_id + ) + ) + ) + INTO v_owns; + + RETURN v_owns; +END; +$EOFCODE$ LANGUAGE plpgsql STABLE SECURITY DEFINER; + +COMMENT ON FUNCTION app_scope.frame_owns(uuid, text, uuid, text, uuid, uuid) IS 'True when the frame that owns a catalog row — its (owner_scope, owner_key, owner_database_id) triple — is one of the frames app_scope.frames returns for the given execution. The identity test is the one function_resolution.resolve probes the catalog with, so a write-path ownership guard admits exactly what the read path would resolve. The chain is ancestry, not visibility: the execution''s own scopes, its database, then the platform database''s chain — never a peer or another keyed owner. Returns false rather than raising when the row''s owning frame is not fully identified.'; \ No newline at end of file diff --git a/packages/app-scope/verify/schemas/app_scope/procedures/actor_entity.sql b/packages/app-scope/verify/schemas/app_scope/procedures/actor_entity.sql new file mode 100644 index 000000000..f35b5410d --- /dev/null +++ b/packages/app-scope/verify/schemas/app_scope/procedures/actor_entity.sql @@ -0,0 +1,7 @@ +-- Verify schemas/app_scope/procedures/actor_entity on pg + +BEGIN; + +SELECT assert_function('app_scope.actor_entity(uuid, uuid)'::regprocedure); + +ROLLBACK; diff --git a/packages/app-scope/verify/schemas/app_scope/procedures/frame_owns.sql b/packages/app-scope/verify/schemas/app_scope/procedures/frame_owns.sql new file mode 100644 index 000000000..40538046b --- /dev/null +++ b/packages/app-scope/verify/schemas/app_scope/procedures/frame_owns.sql @@ -0,0 +1,7 @@ +-- Verify schemas/app_scope/procedures/frame_owns on pg + +BEGIN; + +SELECT assert_function('app_scope.frame_owns(uuid, text, uuid, text, uuid, uuid)'::regprocedure); + +ROLLBACK; diff --git a/packages/database-jobs/__tests__/jobs.test.ts b/packages/database-jobs/__tests__/jobs.test.ts index 941459dac..799c49dad 100644 --- a/packages/database-jobs/__tests__/jobs.test.ts +++ b/packages/database-jobs/__tests__/jobs.test.ts @@ -4,11 +4,15 @@ let pg: PgTestClient; let teardown: () => Promise; const database_id = '5b720132-17d5-424d-9bcb-ee7b17c13d43'; +const actor_id = 'b9d22af1-62c7-43a5-b8c4-50630bbd4962'; +const principal_id = 'd9a8e3c1-5d5c-450d-8a95-95e4f7d9d98c'; +const entity_id = 'f12f1f0d-6f62-4f4b-93f2-72ee5d2a5b8e'; const objs: Record = {}; describe('scheduled jobs', () => { beforeAll(async () => { ({ pg, teardown } = await getConnections()); + await pg.any(`SELECT set_config('jwt.strict_attribution', 'false', false)`); }); afterAll(async () => { @@ -138,23 +142,344 @@ describe('scheduled jobs', () => { it('add_job stamps explicit db_id', async () => { const [job] = await pg.any( - `SELECT * FROM app_jobs.add_job(identifier := 'my_job', db_id := $1::uuid)`, - [database_id] + `SELECT * FROM app_jobs.add_job( + identifier := 'my_job', + db_id := $1::uuid, + actor_id := $2::uuid + )`, + [database_id, 'b9d22af1-62c7-43a5-b8c4-50630bbd4962'] ); expect(job.database_id).toBe(database_id); }); + it('add_job stamps entity attribution from transaction-local claims, but never the organization', async () => { + const user_id = 'b9d22af1-62c7-43a5-b8c4-50630bbd4962'; + const principal_id = 'd9a8e3c1-5d5c-450d-8a95-95e4f7d9d98c'; + const entity_id = 'f12f1f0d-6f62-4f4b-93f2-72ee5d2a5b8e'; + const organization_id = 'c89b6b6b-ea18-4d98-bd5a-3f3c0c34f872'; + + await pg.any(`BEGIN`); + try { + await pg.any( + `SELECT + set_config('jwt.claims.database_id', $1, true), + set_config('jwt.claims.user_id', $2, true), + set_config('jwt.claims.principal_id', $3, true), + set_config('jwt.claims.entity_id', $4, true), + set_config('jwt.claims.organization_id', $5, true), + set_config('jwt.claims.entity_type', $6, true)`, + [database_id, user_id, principal_id, entity_id, organization_id, 'org'] + ); + + const [job] = await pg.any( + `SELECT * FROM app_jobs.add_job( + identifier := 'claimed_job', + job_key := 'claimed_job' + )` + ); + + expect({ + database_id: job.database_id, + actor_id: job.actor_id, + principal_id: job.principal_id, + entity_id: job.entity_id, + organization_id: job.organization_id, + entity_type: job.entity_type + }).toEqual({ + database_id, + actor_id: user_id, + principal_id, + entity_id, + // Set as a claim above and deliberately not inherited: the organization + // is derived from the entity pair where usage is recorded, so a claim + // asserting one is not an authority add_job trusts. + organization_id: null, + entity_type: 'org' + }); + } finally { + await pg.any(`ROLLBACK`); + } + }); + it('add_job fails the NOT NULL constraint without database attribution', async () => { await expect( pg.any(`SELECT * FROM app_jobs.add_job(identifier := 'my_job')`) ).rejects.toThrow(/database_id/); }); + it('add_job rejects claimless attribution at the work boundary', async () => { + await pg.any(`BEGIN`); + await pg.any(`SELECT set_config('jwt.strict_attribution', 'true', true)`); + await expect( + pg.any( + `SELECT * FROM app_jobs.add_job( + identifier := 'claimless_job', + db_id := $1::uuid + )`, + [database_id] + ) + ).rejects.toThrow('ATTRIBUTION_REQUIRED'); + await pg.any(`ROLLBACK`); + }); + + it('add_job rejects an entity without an entity type', async () => { + await pg.any(`BEGIN`); + await pg.any(`SELECT set_config('jwt.strict_attribution', 'true', true)`); + await expect( + pg.any( + `SELECT * FROM app_jobs.add_job( + identifier := 'missing_entity_type_job', + db_id := $1::uuid, + entity_id := $2::uuid + )`, + [database_id, 'f12f1f0d-6f62-4f4b-93f2-72ee5d2a5b8e'] + ) + ).rejects.toThrow('ENTITY_TYPE_REQUIRED'); + await pg.any(`ROLLBACK`); + }); + + it('add_job allows actor-only attribution', async () => { + const actor_id = 'b9d22af1-62c7-43a5-b8c4-50630bbd4962'; + const [job] = await pg.any( + `SELECT * FROM app_jobs.add_job( + identifier := 'actor_only_job', + db_id := $1::uuid, + actor_id := $2::uuid + )`, + [database_id, actor_id] + ); + expect(job.actor_id).toBe(actor_id); + expect(job.entity_id).toBeNull(); + expect(job.entity_type).toBeNull(); + }); + + it('add_job allows row-derived entity attribution with its type', async () => { + const entity_id = 'f12f1f0d-6f62-4f4b-93f2-72ee5d2a5b8e'; + const [job] = await pg.any( + `SELECT * FROM app_jobs.add_job( + identifier := 'entity_attributed_job', + db_id := $1::uuid, + entity_id := $2::uuid, + entity_type := $3::text + )`, + [database_id, entity_id, 'org'] + ); + expect(job.entity_id).toBe(entity_id); + expect(job.entity_type).toBe('org'); + }); + it('add_scheduled_job fails the NOT NULL constraint without database attribution', async () => { await expect( pg.any( `SELECT * FROM app_jobs.add_scheduled_job(identifier := 'my_job')` ) - ).rejects.toThrow(/database_id/); + ).rejects.toThrow('DATABASE_CLAIM_REQUIRED'); + }); + + it('add_scheduled_job defaults complete attribution from transaction-local claims', async () => { + await pg.any(`BEGIN`); + try { + await pg.any( + `SELECT + set_config('jwt.claims.database_id', $1, true), + set_config('jwt.claims.user_id', $2, true), + set_config('jwt.claims.principal_id', $3, true), + set_config('jwt.claims.entity_id', $4, true), + set_config('jwt.claims.entity_type', $5, true)`, + [database_id, actor_id, principal_id, entity_id, 'org'] + ); + + const [scheduled] = await pg.any( + `SELECT * FROM app_jobs.add_scheduled_job( + identifier := 'claimed_scheduled_job', + job_key := 'claimed_scheduled_job' + )` + ); + + expect({ + database_id: scheduled.database_id, + actor_id: scheduled.actor_id, + principal_id: scheduled.principal_id, + entity_id: scheduled.entity_id, + organization_id: scheduled.organization_id, + entity_type: scheduled.entity_type + }).toEqual({ + database_id, + actor_id, + principal_id, + entity_id, + organization_id: null, + entity_type: 'org' + }); + } finally { + await pg.any(`ROLLBACK`); + } + }); + + it.each([ + ['entity id without entity type', 'entity_id := $2::uuid, entity_type := NULL', [database_id, entity_id, actor_id]], + ['entity type without entity id', 'entity_id := NULL, entity_type := $2::text', [database_id, 'org', actor_id]] + ])('add_scheduled_job warns by default for %s', async (_description, attribution, params) => { + // The session default is explicitly non-strict in beforeAll. + await pg.any(`BEGIN`); + try { + const [scheduled] = await pg.any( + `SELECT * FROM app_jobs.add_scheduled_job( + identifier := 'partial_scheduled_job', + db_id := $1::uuid, + actor_id := $3::uuid, + ${attribution} + )`, + params + ); + expect(scheduled).toBeDefined(); + } finally { + await pg.any(`ROLLBACK`); + } + }); + + it.each([ + ['entity id without entity type', 'entity_id := $2::uuid, entity_type := NULL', [database_id, entity_id, actor_id], 'ENTITY_TYPE_REQUIRED'], + ['entity type without entity id', 'entity_id := NULL, entity_type := $2::text', [database_id, 'org', actor_id], 'ENTITY_ID_REQUIRED'] + ])('add_scheduled_job rejects %s under strict attribution', async (_description, attribution, params, error) => { + await pg.any(`BEGIN`); + try { + await pg.any(`SELECT set_config('jwt.strict_attribution', 'true', true)`); + await expect( + pg.any( + `SELECT * FROM app_jobs.add_scheduled_job( + identifier := 'strict_partial_scheduled_job', + db_id := $1::uuid, + actor_id := $3::uuid, + ${attribution} + )`, + params + ) + ).rejects.toThrow(error); + } finally { + await pg.any(`ROLLBACK`); + } + }); + + it('add_scheduled_job restamps keyed rows with the caller identity', async () => { + await pg.any(`BEGIN`); + try { + await pg.any( + `SELECT + set_config('jwt.claims.database_id', $1, true), + set_config('jwt.claims.user_id', $2, true), + set_config('jwt.claims.principal_id', $3, true), + set_config('jwt.claims.entity_id', $4, true), + set_config('jwt.claims.entity_type', $5, true)`, + [database_id, actor_id, principal_id, entity_id, 'org'] + ); + await pg.any( + `SELECT * FROM app_jobs.add_scheduled_job( + identifier := 'restamp_scheduled_job', + job_key := 'restamp_scheduled_job' + )` + ); + + const next_actor_id = 'f89a41e7-7e41-4c63-b6c7-e99d7a122f70'; + const next_principal_id = '4ae9a4f9-f785-4d4e-8e4b-2aa367ec4d7a'; + const next_entity_id = '8f09e1b0-eae4-4e76-918a-1e916f6f7c4c'; + await pg.any( + `SELECT + set_config('jwt.claims.user_id', $1, true), + set_config('jwt.claims.principal_id', $2, true), + set_config('jwt.claims.entity_id', $3, true), + set_config('jwt.claims.entity_type', $4, true)`, + [next_actor_id, next_principal_id, next_entity_id, 'team'] + ); + const [scheduled] = await pg.any( + `SELECT * FROM app_jobs.add_scheduled_job( + identifier := 'restamped_scheduled_job', + job_key := 'restamp_scheduled_job' + )` + ); + + expect({ + database_id: scheduled.database_id, + actor_id: scheduled.actor_id, + principal_id: scheduled.principal_id, + entity_id: scheduled.entity_id, + organization_id: scheduled.organization_id, + entity_type: scheduled.entity_type + }).toEqual({ + database_id, + actor_id: next_actor_id, + principal_id: next_principal_id, + entity_id: next_entity_id, + organization_id: null, + entity_type: 'team' + }); + } finally { + await pg.any(`ROLLBACK`); + } + }); + + it('run_scheduled_job copies complete attribution to the spawned job', async () => { + const scheduled = await pg.one( + `INSERT INTO app_jobs.scheduled_jobs ( + database_id, actor_id, principal_id, entity_id, organization_id, + entity_type, task_identifier, schedule_info + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING *`, + [ + database_id, + actor_id, + principal_id, + entity_id, + 'c89b6b6b-ea18-4d98-bd5a-3f3c0c34f872', + 'org', + 'attributed_scheduled_job', + { start: new Date(Date.now() + 10000), end: new Date(Date.now() + 180000) } + ] + ); + const [job] = await pg.any( + `SELECT * FROM app_jobs.run_scheduled_job($1)`, + [scheduled.id] + ); + expect({ + database_id: job.database_id, + actor_id: job.actor_id, + principal_id: job.principal_id, + entity_id: job.entity_id, + organization_id: job.organization_id, + entity_type: job.entity_type + }).toEqual({ + database_id, + actor_id, + principal_id, + entity_id, + organization_id: 'c89b6b6b-ea18-4d98-bd5a-3f3c0c34f872', + entity_type: 'org' + }); + }); + + it('run_scheduled_job rejects a malformed entity pair under strict attribution', async () => { + const task_identifier = 'malformed_scheduled_job'; + await pg.any(`BEGIN`); + try { + await pg.any(`SELECT set_config('jwt.strict_attribution', 'true', true)`); + const scheduled = await pg.one( + `INSERT INTO app_jobs.scheduled_jobs ( + database_id, actor_id, entity_id, task_identifier, schedule_info + ) VALUES ($1, $2, $3, $4, $5) + RETURNING id`, + [database_id, actor_id, entity_id, task_identifier, {}] + ); + await expect( + pg.any(`SELECT * FROM app_jobs.run_scheduled_job($1)`, [scheduled.id]) + ).rejects.toThrow('ENTITY_TYPE_REQUIRED'); + } finally { + await pg.any(`ROLLBACK`); + } + const [{ count }] = await pg.any( + `SELECT count(*)::int AS count FROM app_jobs.jobs + WHERE task_identifier = $1`, + [task_identifier] + ); + expect(count).toBe(0); }); }); \ No newline at end of file diff --git a/packages/database-jobs/__tests__/queue-locks.test.ts b/packages/database-jobs/__tests__/queue-locks.test.ts new file mode 100644 index 000000000..140137a4a --- /dev/null +++ b/packages/database-jobs/__tests__/queue-locks.test.ts @@ -0,0 +1,134 @@ +import { getConnections, PgTestClient } from 'pgsql-test'; + +/** + * queue_name is a mutual-exclusion lock: get_job holds the queue row for the + * whole job and claims nothing else on it. The shared literal 'default' is a + * routing label an author never chose, so it must not become a lock domain that + * serializes every job in the database. + */ + +let pg: PgTestClient; +let teardown: () => Promise; + +const database_id = '5b720132-17d5-424d-9bcb-ee7b17c13d43'; + +const addJob = async (identifier: string, queue_name: string | null) => { + const [job] = await pg.any<{ id: string; queue_name: string | null }>( + `SELECT * FROM app_jobs.add_job( + identifier := $1::text, + queue_name := $2::text, + db_id := $3::uuid + )`, + [identifier, queue_name, database_id] + ); + return job; +}; + +describe('queue names as locks', () => { + beforeAll(async () => { + ({ pg, teardown } = await getConnections()); + await pg.any(`SELECT set_config('jwt.strict_attribution', 'false', false)`); + }); + + afterAll(async () => { + await teardown(); + }); + + beforeEach(async () => { + await pg.any(`DELETE FROM app_jobs.jobs`); + }); + + it('records the shared literal, and a blank name, as no lock at all', async () => { + const shared = await addJob('shared_literal', 'default'); + const blank = await addJob('blank_name', ''); + + expect(shared.queue_name).toBeNull(); + expect(blank.queue_name).toBeNull(); + + const queues = await pg.any( + `SELECT queue_name FROM app_jobs.job_queues WHERE queue_name IN ('default', '')` + ); + expect(queues).toEqual([]); + }); + + it('keeps a name its author chose', async () => { + const job = await addJob('chosen_name', 'email'); + expect(job.queue_name).toBe('email'); + + const [queue] = await pg.any<{ job_count: number }>( + `SELECT job_count FROM app_jobs.job_queues WHERE queue_name = 'email'` + ); + expect(queue.job_count).toBe(1); + }); + + it('lets two workers claim jobs that carried the shared literal', async () => { + await addJob('shared_a', 'default'); + await addJob('shared_b', 'default'); + + const first = await pg.one<{ id: string | null }>( + `SELECT id FROM app_jobs.get_job('worker_one')` + ); + const second = await pg.one<{ id: string | null }>( + `SELECT id FROM app_jobs.get_job('worker_two')` + ); + + expect(first.id).not.toBeNull(); + expect(second.id).not.toBeNull(); + expect(second.id).not.toBe(first.id); + }); + + it('still serializes a name its author chose', async () => { + await addJob('email_a', 'email'); + await addJob('email_b', 'email'); + + const first = await pg.one<{ id: string | null }>( + `SELECT id FROM app_jobs.get_job('worker_one')` + ); + const second = await pg.one<{ id: string | null }>( + `SELECT id FROM app_jobs.get_job('worker_two')` + ); + + expect(first.id).not.toBeNull(); + expect(second.id).toBeNull(); + }); + + const addSchedule = async (task_identifier: string) => + pg.one<{ id: string }>( + `INSERT INTO app_jobs.scheduled_jobs (database_id, task_identifier, queue_name, schedule_info) + VALUES ($1, $2, 'default', $3) + RETURNING id`, + [database_id, task_identifier, { start: new Date(), rule: '*/1 * * * *' }] + ); + + it('spawns a scheduled job carrying the shared literal without a lock', async () => { + const schedule = await addSchedule('scheduled_shared'); + + const [job] = await pg.any<{ queue_name: string | null }>( + `SELECT queue_name FROM app_jobs.run_scheduled_job($1)`, + [schedule.id] + ); + + expect(job.queue_name).toBeNull(); + }); + + // A schedule's protection against overlapping itself is its own, and does not + // come from the queue: run_scheduled_job refuses a tick while the previous + // one is still running. Dropping the shared queue therefore lets *different* + // schedules overlap, never a schedule with itself. + it('refuses a second tick while the first is still running', async () => { + const schedule = await addSchedule('scheduled_reentrant'); + + const [job] = await pg.any<{ id: string }>( + `SELECT id FROM app_jobs.run_scheduled_job($1)`, + [schedule.id] + ); + await pg.any( + `UPDATE app_jobs.jobs SET locked_at = now(), locked_by = 'worker_one' WHERE id = $1`, + [job.id] + ); + + await expect( + pg.any(`SELECT id FROM app_jobs.run_scheduled_job($1)`, [schedule.id]) + ).rejects.toThrow('ALREADY_SCHEDULED'); + }); +}); diff --git a/packages/database-jobs/deploy/schemas/app_jobs/procedures/add_job.sql b/packages/database-jobs/deploy/schemas/app_jobs/procedures/add_job.sql index dbcc6a875..2f3aab04b 100644 --- a/packages/database-jobs/deploy/schemas/app_jobs/procedures/add_job.sql +++ b/packages/database-jobs/deploy/schemas/app_jobs/procedures/add_job.sql @@ -2,7 +2,10 @@ -- requires: schemas/app_jobs/schema -- requires: schemas/app_jobs/tables/jobs/table -- requires: schemas/app_jobs/tables/job_queues/table --- requires: pgpm-jwt-claims:schemas/jwt_private/procedures/current_database_id +-- requires: pgpm-jwt-claims:schemas/jwt_private/procedures/current_entity_id +-- requires: pgpm-jwt-claims:schemas/jwt_private/procedures/current_entity_type +-- requires: pgpm-jwt-claims:schemas/jwt_private/procedures/require_database_id +-- requires: pgpm-jwt-claims:schemas/jwt_private/procedures/assert_attribution -- requires: pgpm-jwt-claims:schemas/jwt_public/procedures/current_user_id -- requires: pgpm-jwt-claims:schemas/jwt_public/procedures/current_principal_id @@ -15,12 +18,17 @@ CREATE FUNCTION app_jobs.add_job ( run_at timestamptz DEFAULT now(), max_attempts integer DEFAULT 25, priority integer DEFAULT 0, - entity_id uuid DEFAULT NULL, + entity_id uuid DEFAULT jwt_private.current_entity_id(), + -- The organization is never a claim: it is derived from the entity pair by + -- get_organization_id at the point of recording, so only a caller that + -- already resolved it (a data-job trigger with an entity field) passes one. organization_id uuid DEFAULT NULL, - entity_type text DEFAULT NULL, + entity_type text DEFAULT jwt_private.current_entity_type(), function_definition_id uuid DEFAULT NULL, definition_scope text DEFAULT NULL, - db_id uuid DEFAULT jwt_private.current_database_id() + db_id uuid DEFAULT jwt_private.require_database_id(), + actor_id uuid DEFAULT jwt_public.current_user_id(), + principal_id uuid DEFAULT jwt_public.current_principal_id() ) RETURNS app_jobs.jobs AS $$ @@ -29,6 +37,7 @@ DECLARE v_database_id uuid; v_actor_id uuid; v_principal_id uuid; + v_queue_name text; BEGIN -- db_id defaults to the session's database claim; only callers that act on -- behalf of a different database (e.g. platform-owned births) pass it @@ -36,9 +45,20 @@ BEGIN -- session with no explicit db_id is rejected by the default expression rather -- than producing an unattributable job. v_database_id := db_id; - v_actor_id := jwt_public.current_user_id(); - - v_principal_id := jwt_public.current_principal_id(); + v_actor_id := add_job.actor_id; + v_principal_id := add_job.principal_id; + -- queue_name is a mutual-exclusion lock, never a routing label: get_job holds + -- the queue row for the whole job and claims nothing else on it, and no worker + -- selects work by queue. A name shared by unrelated jobs therefore serializes + -- all of them, and 'default' is precisely that name -- what a function + -- definition carries when its author asked for no serialization at all. Read + -- it, and the empty string, as no lock. + v_queue_name := nullif(nullif(add_job.queue_name, ''), 'default'); + PERFORM jwt_private.assert_attribution( + v_actor_id, + add_job.entity_id, + add_job.entity_type + ); IF job_key IS NOT NULL THEN -- Upsert job @@ -69,7 +89,7 @@ BEGIN add_job.definition_scope, identifier, coalesce(payload, '{}'::json), - queue_name, + v_queue_name, coalesce(run_at, now()), coalesce(max_attempts, 25), job_key, @@ -135,7 +155,7 @@ BEGIN add_job.definition_scope, identifier, payload, - queue_name, + v_queue_name, run_at, max_attempts, priority diff --git a/packages/database-jobs/deploy/schemas/app_jobs/procedures/add_scheduled_job.sql b/packages/database-jobs/deploy/schemas/app_jobs/procedures/add_scheduled_job.sql index b00609fa1..28df66233 100644 --- a/packages/database-jobs/deploy/schemas/app_jobs/procedures/add_scheduled_job.sql +++ b/packages/database-jobs/deploy/schemas/app_jobs/procedures/add_scheduled_job.sql @@ -2,8 +2,12 @@ -- requires: schemas/app_jobs/schema -- requires: schemas/app_jobs/tables/scheduled_jobs/table --- requires: pgpm-jwt-claims:schemas/jwt_private/procedures/current_database_id +-- requires: pgpm-jwt-claims:schemas/jwt_private/procedures/current_entity_id +-- requires: pgpm-jwt-claims:schemas/jwt_private/procedures/current_entity_type +-- requires: pgpm-jwt-claims:schemas/jwt_private/procedures/require_database_id +-- requires: pgpm-jwt-claims:schemas/jwt_private/procedures/assert_attribution -- requires: pgpm-jwt-claims:schemas/jwt_public/procedures/current_user_id +-- requires: pgpm-jwt-claims:schemas/jwt_public/procedures/current_principal_id BEGIN; @@ -15,8 +19,15 @@ CREATE FUNCTION app_jobs.add_scheduled_job( queue_name text DEFAULT NULL, max_attempts integer DEFAULT 25, priority integer DEFAULT 0, - entity_id uuid DEFAULT NULL, - db_id uuid DEFAULT jwt_private.current_database_id() + entity_id uuid DEFAULT jwt_private.current_entity_id(), + db_id uuid DEFAULT jwt_private.require_database_id(), + entity_type text DEFAULT jwt_private.current_entity_type(), + -- The organization is never a claim: it is derived from the entity pair by + -- get_organization_id at the point of recording, so only a caller that + -- already resolved it (a data-job trigger with an entity field) passes one. + organization_id uuid DEFAULT NULL, + actor_id uuid DEFAULT jwt_public.current_user_id(), + principal_id uuid DEFAULT jwt_public.current_principal_id() ) RETURNS app_jobs.scheduled_jobs AS $$ @@ -24,6 +35,7 @@ DECLARE v_job app_jobs.scheduled_jobs; v_database_id uuid; v_actor_id uuid; + v_principal_id uuid; BEGIN -- db_id defaults to the session's database claim; only callers that act on -- behalf of a different database (e.g. provisioning triggers, platform-owned @@ -32,7 +44,13 @@ BEGIN -- scheduled_jobs.database_id NOT NULL constraint rather than producing an -- unattributable job. v_database_id := db_id; - v_actor_id := jwt_public.current_user_id(); + v_actor_id := add_scheduled_job.actor_id; + v_principal_id := add_scheduled_job.principal_id; + PERFORM jwt_private.assert_attribution( + v_actor_id, + add_scheduled_job.entity_id, + add_scheduled_job.entity_type + ); IF job_key IS NOT NULL THEN @@ -40,7 +58,10 @@ BEGIN INSERT INTO app_jobs.scheduled_jobs ( database_id, actor_id, + principal_id, entity_id, + organization_id, + entity_type, task_identifier, payload, queue_name, @@ -51,7 +72,10 @@ BEGIN ) VALUES ( v_database_id, v_actor_id, + v_principal_id, add_scheduled_job.entity_id, + add_scheduled_job.organization_id, + add_scheduled_job.entity_type, identifier, coalesce(payload, '{}'::json), queue_name, @@ -62,6 +86,12 @@ BEGIN ) ON CONFLICT (key) DO UPDATE SET + database_id = EXCLUDED.database_id, + actor_id = EXCLUDED.actor_id, + principal_id = EXCLUDED.principal_id, + entity_id = EXCLUDED.entity_id, + organization_id = EXCLUDED.organization_id, + entity_type = EXCLUDED.entity_type, task_identifier = EXCLUDED.task_identifier, payload = EXCLUDED.payload, queue_name = EXCLUDED.queue_name, @@ -91,7 +121,10 @@ BEGIN INSERT INTO app_jobs.scheduled_jobs ( database_id, actor_id, + principal_id, entity_id, + organization_id, + entity_type, task_identifier, payload, queue_name, @@ -101,7 +134,10 @@ BEGIN ) VALUES ( v_database_id, v_actor_id, + v_principal_id, add_scheduled_job.entity_id, + add_scheduled_job.organization_id, + add_scheduled_job.entity_type, identifier, payload, queue_name, @@ -116,4 +152,3 @@ LANGUAGE 'plpgsql' VOLATILE SECURITY DEFINER; COMMIT; - diff --git a/packages/database-jobs/deploy/schemas/app_jobs/procedures/grants/grant_execute_add_job_to_authenticated.sql b/packages/database-jobs/deploy/schemas/app_jobs/procedures/grants/grant_execute_add_job_to_authenticated.sql index 66a0daec5..1e09cd8f4 100644 --- a/packages/database-jobs/deploy/schemas/app_jobs/procedures/grants/grant_execute_add_job_to_authenticated.sql +++ b/packages/database-jobs/deploy/schemas/app_jobs/procedures/grants/grant_execute_add_job_to_authenticated.sql @@ -5,6 +5,6 @@ BEGIN; -GRANT EXECUTE ON FUNCTION app_jobs.add_job(text, json, text, text, timestamptz, integer, integer, uuid, uuid, text, uuid, text, uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION app_jobs.add_job(text, json, text, text, timestamptz, integer, integer, uuid, uuid, text, uuid, text, uuid, uuid, uuid) TO authenticated; COMMIT; diff --git a/packages/database-jobs/deploy/schemas/app_jobs/procedures/grants/grant_execute_add_scheduled_job_to_authenticated.sql b/packages/database-jobs/deploy/schemas/app_jobs/procedures/grants/grant_execute_add_scheduled_job_to_authenticated.sql index b0daa37ca..8c03f3972 100644 --- a/packages/database-jobs/deploy/schemas/app_jobs/procedures/grants/grant_execute_add_scheduled_job_to_authenticated.sql +++ b/packages/database-jobs/deploy/schemas/app_jobs/procedures/grants/grant_execute_add_scheduled_job_to_authenticated.sql @@ -5,6 +5,6 @@ BEGIN; -GRANT EXECUTE ON FUNCTION app_jobs.add_scheduled_job(text, json, json, text, text, integer, integer, uuid, uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION app_jobs.add_scheduled_job(text, json, json, text, text, integer, integer, uuid, uuid, text, uuid, uuid, uuid) TO authenticated; COMMIT; diff --git a/packages/database-jobs/deploy/schemas/app_jobs/procedures/run_scheduled_job.sql b/packages/database-jobs/deploy/schemas/app_jobs/procedures/run_scheduled_job.sql index 935bcea77..92a3244a6 100644 --- a/packages/database-jobs/deploy/schemas/app_jobs/procedures/run_scheduled_job.sql +++ b/packages/database-jobs/deploy/schemas/app_jobs/procedures/run_scheduled_job.sql @@ -2,6 +2,7 @@ -- requires: schemas/app_jobs/schema -- requires: schemas/app_jobs/tables/jobs/table -- requires: schemas/app_jobs/tables/scheduled_jobs/table +-- requires: pgpm-jwt-claims:schemas/jwt_private/procedures/assert_attribution BEGIN; CREATE FUNCTION app_jobs.run_scheduled_job (id bigint, job_expiry interval DEFAULT '1 hours') @@ -43,6 +44,12 @@ BEGIN END IF; END IF; + PERFORM jwt_private.assert_attribution( + sched.actor_id, + sched.entity_id, + sched.entity_type + ); + -- a job carrying this key that is already in flight covers this tick, and the -- keyed upsert below cannot refresh a locked row IF (sched.key IS NOT NULL) THEN @@ -64,7 +71,10 @@ BEGIN INSERT INTO app_jobs.jobs ( database_id, actor_id, + principal_id, entity_id, + organization_id, + entity_type, queue_name, task_identifier, payload, @@ -74,8 +84,14 @@ BEGIN ) VALUES ( sched.database_id, sched.actor_id, + sched.principal_id, sched.entity_id, - sched.queue_name, + sched.organization_id, + sched.entity_type, + -- same reading as app_jobs.add_job: the shared literal 'default' (copied + -- onto a schedule from its function definition) is not a lock domain, so a + -- tick of one schedule must not block every other job in the database. + nullif(nullif(sched.queue_name, ''), 'default'), sched.task_identifier, sched.payload, sched.priority, @@ -84,7 +100,8 @@ BEGIN ) ON CONFLICT (KEY) DO UPDATE SET - database_id = excluded.database_id, actor_id = excluded.actor_id, entity_id = excluded.entity_id, + database_id = excluded.database_id, actor_id = excluded.actor_id, principal_id = excluded.principal_id, + entity_id = excluded.entity_id, organization_id = excluded.organization_id, entity_type = excluded.entity_type, task_identifier = excluded.task_identifier, payload = excluded.payload, queue_name = excluded.queue_name, max_attempts = excluded.max_attempts, priority = excluded.priority, run_at = excluded.run_at, -- always reset error/retry state attempts = 0, last_error = NULL diff --git a/packages/database-jobs/deploy/schemas/app_jobs/tables/jobs/table.sql b/packages/database-jobs/deploy/schemas/app_jobs/tables/jobs/table.sql index 729ce48fd..5240d8722 100644 --- a/packages/database-jobs/deploy/schemas/app_jobs/tables/jobs/table.sql +++ b/packages/database-jobs/deploy/schemas/app_jobs/tables/jobs/table.sql @@ -42,8 +42,8 @@ COMMENT ON COLUMN app_jobs.jobs.id IS 'Auto-incrementing job identifier'; COMMENT ON COLUMN app_jobs.jobs.database_id IS 'Database this job belongs to; every job is owned by exactly one database'; COMMENT ON COLUMN app_jobs.jobs.actor_id IS 'User who triggered this job, read from JWT claims at enqueue time'; COMMENT ON COLUMN app_jobs.jobs.principal_id IS 'Principal that triggered this job; equals actor_id for human-triggered jobs, differs when an agent/API-key acts on behalf of a user'; -COMMENT ON COLUMN app_jobs.jobs.entity_id IS 'Entity (org/team) this job is scoped to for billing; NULL means platform-level (resolved via database_id → owner_id)'; -COMMENT ON COLUMN app_jobs.jobs.organization_id IS 'Top-level organization for this entity; resolved at enqueue time via get_organization_id(entity_type, entity_id)'; +COMMENT ON COLUMN app_jobs.jobs.entity_id IS 'Entity this job is attributed to for billing; read from the transaction entity claim at enqueue time; NULL means the claim was absent, not platform-level'; +COMMENT ON COLUMN app_jobs.jobs.organization_id IS 'Organization this job is attributed to; resolved from the entity pair via get_organization_id at enqueue time by callers that know it (e.g. data-job triggers) — never read from a claim'; COMMENT ON COLUMN app_jobs.jobs.entity_type IS 'Entity type prefix (org, team, app, etc.) for interpreting entity_id'; COMMENT ON COLUMN app_jobs.jobs.function_definition_id IS 'For function jobs: the exact function definition resolved at enqueue time (scope-chain winner). NULL for handler/system tasks that have no function definition. Not an FK — definitions live in per-scope tables across databases; integrity is enforced by the resolver at enqueue.'; COMMENT ON COLUMN app_jobs.jobs.definition_scope IS 'For function jobs: the scope (database/org/app/platform) the winning definition was resolved at. Together with function_definition_id and database_id it identifies the exact physical definition to execute. NULL when function_definition_id is NULL.'; @@ -61,4 +61,3 @@ COMMENT ON COLUMN app_jobs.jobs.locked_by IS 'Identifier of the worker that curr COMMENT ON COLUMN app_jobs.jobs.is_available IS 'Generated column: true when job is unlocked and has remaining attempts'; COMMIT; - diff --git a/packages/database-jobs/deploy/schemas/app_jobs/tables/scheduled_jobs/table.sql b/packages/database-jobs/deploy/schemas/app_jobs/tables/scheduled_jobs/table.sql index cbf89eba3..493b422a0 100644 --- a/packages/database-jobs/deploy/schemas/app_jobs/tables/scheduled_jobs/table.sql +++ b/packages/database-jobs/deploy/schemas/app_jobs/tables/scheduled_jobs/table.sql @@ -6,7 +6,10 @@ CREATE TABLE app_jobs.scheduled_jobs ( id bigserial PRIMARY KEY, database_id uuid NOT NULL, actor_id uuid, + principal_id uuid, entity_id uuid, + organization_id uuid, + entity_type text, queue_name text DEFAULT NULL, task_identifier text NOT NULL, payload json DEFAULT '{}' ::json NOT NULL, @@ -30,7 +33,10 @@ COMMENT ON TABLE app_jobs.scheduled_jobs IS 'Recurring/cron-style job definition COMMENT ON COLUMN app_jobs.scheduled_jobs.id IS 'Auto-incrementing scheduled job identifier'; COMMENT ON COLUMN app_jobs.scheduled_jobs.database_id IS 'Database this scheduled job belongs to; every scheduled job is owned by exactly one database'; COMMENT ON COLUMN app_jobs.scheduled_jobs.actor_id IS 'User who created this scheduled job, read from JWT claims at creation time'; -COMMENT ON COLUMN app_jobs.scheduled_jobs.entity_id IS 'Entity (org/team) this scheduled job is scoped to for billing; NULL means platform-level (resolved via database_id → owner_id)'; +COMMENT ON COLUMN app_jobs.scheduled_jobs.principal_id IS 'Principal that triggered this scheduled job; equals actor_id for human-triggered jobs, differs when an agent/API-key acts on behalf of a user'; +COMMENT ON COLUMN app_jobs.scheduled_jobs.entity_id IS 'Entity this scheduled job is attributed to for billing; read from the transaction entity claim at registration time; NULL means the claim was absent, not platform-level'; +COMMENT ON COLUMN app_jobs.scheduled_jobs.organization_id IS 'Organization this scheduled job is attributed to; resolved from the entity pair via get_organization_id at registration time by callers that know it (e.g. data-job triggers) — never read from a claim'; +COMMENT ON COLUMN app_jobs.scheduled_jobs.entity_type IS 'Entity type prefix (org, team, app, etc.) for interpreting entity_id'; COMMENT ON COLUMN app_jobs.scheduled_jobs.queue_name IS 'Name of the queue spawned jobs are placed into'; COMMENT ON COLUMN app_jobs.scheduled_jobs.task_identifier IS 'Task type identifier for spawned jobs'; COMMENT ON COLUMN app_jobs.scheduled_jobs.payload IS 'JSON payload passed to each spawned job'; @@ -44,4 +50,3 @@ COMMENT ON COLUMN app_jobs.scheduled_jobs.last_scheduled IS 'Timestamp when a jo COMMENT ON COLUMN app_jobs.scheduled_jobs.last_scheduled_id IS 'ID of the last job spawned from this schedule'; COMMIT; - diff --git a/packages/database-jobs/pgpm.plan b/packages/database-jobs/pgpm.plan index 7e4b6e919..dcd550f0f 100644 --- a/packages/database-jobs/pgpm.plan +++ b/packages/database-jobs/pgpm.plan @@ -24,7 +24,7 @@ schemas/app_jobs/tables/jobs/grants/grant_select_insert_update_delete_to_adminis schemas/app_jobs/tables/job_queues/table [schemas/app_jobs/schema] 2025-08-26T23:57:41Z pgpm # add schemas/app_jobs/tables/job_queues/table schemas/app_jobs/tables/job_queues/indexes/job_queues_locked_by_idx [schemas/app_jobs/schema schemas/app_jobs/tables/job_queues/table] 2025-08-26T23:57:41Z pgpm # add schemas/app_jobs/tables/job_queues/indexes/job_queues_locked_by_idx schemas/app_jobs/tables/job_queues/grants/grant_select_insert_update_delete_to_administrator [schemas/app_jobs/schema schemas/app_jobs/tables/job_queues/table] 2025-08-26T23:57:41Z pgpm # add schemas/app_jobs/tables/job_queues/grants/grant_select_insert_update_delete_to_administrator -schemas/app_jobs/procedures/run_scheduled_job [schemas/app_jobs/schema schemas/app_jobs/tables/jobs/table schemas/app_jobs/tables/scheduled_jobs/table] 2025-08-26T23:57:41Z pgpm # add schemas/app_jobs/procedures/run_scheduled_job +schemas/app_jobs/procedures/run_scheduled_job [schemas/app_jobs/schema schemas/app_jobs/tables/jobs/table schemas/app_jobs/tables/scheduled_jobs/table pgpm-jwt-claims:schemas/jwt_private/procedures/assert_attribution] 2025-08-26T23:57:41Z pgpm # add schemas/app_jobs/procedures/run_scheduled_job schemas/app_jobs/procedures/reschedule_jobs [schemas/app_jobs/schema schemas/app_jobs/tables/jobs/table] 2025-08-26T23:57:41Z pgpm # add schemas/app_jobs/procedures/reschedule_jobs schemas/app_jobs/procedures/release_scheduled_jobs [schemas/app_jobs/schema schemas/app_jobs/tables/scheduled_jobs/table] 2025-08-26T23:57:41Z pgpm # add schemas/app_jobs/procedures/release_scheduled_jobs schemas/app_jobs/procedures/release_jobs [schemas/app_jobs/schema schemas/app_jobs/tables/jobs/table schemas/app_jobs/tables/job_queues/table] 2025-08-26T23:57:41Z pgpm # add schemas/app_jobs/procedures/release_jobs @@ -34,10 +34,10 @@ schemas/app_jobs/procedures/get_job [schemas/app_jobs/schema schemas/app_jobs/ta schemas/app_jobs/procedures/fail_job [schemas/app_jobs/schema schemas/app_jobs/tables/jobs/table schemas/app_jobs/tables/job_queues/table] 2025-08-26T23:57:41Z pgpm # add schemas/app_jobs/procedures/fail_job schemas/app_jobs/procedures/complete_jobs [schemas/app_jobs/schema schemas/app_jobs/tables/job_queues/table schemas/app_jobs/tables/jobs/table] 2025-08-26T23:57:41Z pgpm # add schemas/app_jobs/procedures/complete_jobs schemas/app_jobs/procedures/complete_job [schemas/app_jobs/schema schemas/app_jobs/tables/jobs/table schemas/app_jobs/tables/job_queues/table] 2025-08-26T23:57:41Z pgpm # add schemas/app_jobs/procedures/complete_job -schemas/app_jobs/procedures/add_scheduled_job [schemas/app_jobs/schema schemas/app_jobs/tables/scheduled_jobs/table pgpm-jwt-claims:schemas/jwt_private/procedures/current_database_id] 2025-08-26T23:57:41Z pgpm # add schemas/app_jobs/procedures/add_scheduled_job +schemas/app_jobs/procedures/add_scheduled_job [schemas/app_jobs/schema schemas/app_jobs/tables/scheduled_jobs/table pgpm-jwt-claims:schemas/jwt_private/procedures/current_entity_id pgpm-jwt-claims:schemas/jwt_private/procedures/current_entity_type pgpm-jwt-claims:schemas/jwt_private/procedures/require_database_id pgpm-jwt-claims:schemas/jwt_private/procedures/assert_attribution pgpm-jwt-claims:schemas/jwt_public/procedures/current_user_id pgpm-jwt-claims:schemas/jwt_public/procedures/current_principal_id] 2025-08-26T23:57:41Z pgpm # add schemas/app_jobs/procedures/add_scheduled_job schemas/app_jobs/procedures/grants/grant_execute_add_scheduled_job_to_authenticated [schemas/app_jobs/schema schemas/app_jobs/procedures/add_scheduled_job] 2026-07-15T06:07:00Z pgpm # grant authenticated EXECUTE on add_scheduled_job for INVOKER trigger support schemas/app_jobs/procedures/schedule_min_interval_seconds [schemas/app_jobs/schema] 2026-07-12T13:30:00Z pgpm # add schedule_min_interval_seconds estimator for schedule interval caps -schemas/app_jobs/procedures/add_job [schemas/app_jobs/schema schemas/app_jobs/tables/jobs/table schemas/app_jobs/tables/job_queues/table pgpm-jwt-claims:schemas/jwt_private/procedures/current_database_id pgpm-jwt-claims:schemas/jwt_public/procedures/current_user_id] 2025-08-26T23:57:41Z pgpm # add schemas/app_jobs/procedures/add_job +schemas/app_jobs/procedures/add_job [schemas/app_jobs/schema schemas/app_jobs/tables/jobs/table schemas/app_jobs/tables/job_queues/table pgpm-jwt-claims:schemas/jwt_private/procedures/require_database_id pgpm-jwt-claims:schemas/jwt_private/procedures/assert_attribution pgpm-jwt-claims:schemas/jwt_public/procedures/current_user_id] 2025-08-26T23:57:41Z pgpm # add schemas/app_jobs/procedures/add_job schemas/app_jobs/procedures/grants/grant_execute_add_job_to_authenticated [schemas/app_jobs/schema schemas/app_jobs/procedures/add_job] 2026-06-03T01:15:00Z pgpm # grant authenticated EXECUTE on add_job for INVOKER trigger support schemas/app_jobs/procedures/remove_job [schemas/app_jobs/schema schemas/app_jobs/tables/jobs/table] 2025-08-26T23:57:41Z pgpm # add schemas/app_jobs/procedures/remove_job schemas/app_jobs/procedures/force_unlock_workers [schemas/app_jobs/schema schemas/app_jobs/tables/jobs/table schemas/app_jobs/tables/job_queues/table] 2025-08-26T23:57:41Z pgpm # add schemas/app_jobs/procedures/force_unlock_workers diff --git a/packages/database-jobs/revert/schemas/app_jobs/procedures/add_job.sql b/packages/database-jobs/revert/schemas/app_jobs/procedures/add_job.sql index 10b801c1b..100e461f4 100644 --- a/packages/database-jobs/revert/schemas/app_jobs/procedures/add_job.sql +++ b/packages/database-jobs/revert/schemas/app_jobs/procedures/add_job.sql @@ -2,6 +2,6 @@ BEGIN; -DROP FUNCTION app_jobs.add_job(text, json, text, text, timestamptz, int4, int4, uuid, uuid, text, uuid, text, uuid); +DROP FUNCTION app_jobs.add_job(text, json, text, text, timestamptz, int4, int4, uuid, uuid, text, uuid, text, uuid, uuid, uuid); COMMIT; diff --git a/packages/database-jobs/revert/schemas/app_jobs/procedures/add_scheduled_job.sql b/packages/database-jobs/revert/schemas/app_jobs/procedures/add_scheduled_job.sql index d6f6cb544..7fe573b73 100644 --- a/packages/database-jobs/revert/schemas/app_jobs/procedures/add_scheduled_job.sql +++ b/packages/database-jobs/revert/schemas/app_jobs/procedures/add_scheduled_job.sql @@ -2,6 +2,6 @@ BEGIN; -DROP FUNCTION app_jobs.add_scheduled_job(text, json, json, text, text, int4, int4, uuid, uuid); +DROP FUNCTION app_jobs.add_scheduled_job(text, json, json, text, text, int4, int4, uuid, uuid, text, uuid, uuid, uuid); COMMIT; diff --git a/packages/database-jobs/revert/schemas/app_jobs/procedures/grants/grant_execute_add_job_to_authenticated.sql b/packages/database-jobs/revert/schemas/app_jobs/procedures/grants/grant_execute_add_job_to_authenticated.sql index b5c218f1c..22a615dfe 100644 --- a/packages/database-jobs/revert/schemas/app_jobs/procedures/grants/grant_execute_add_job_to_authenticated.sql +++ b/packages/database-jobs/revert/schemas/app_jobs/procedures/grants/grant_execute_add_job_to_authenticated.sql @@ -2,6 +2,6 @@ BEGIN; -REVOKE EXECUTE ON FUNCTION app_jobs.add_job(text, json, text, text, timestamptz, integer, integer, uuid, uuid, text, uuid, text, uuid) FROM authenticated; +REVOKE EXECUTE ON FUNCTION app_jobs.add_job(text, json, text, text, timestamptz, integer, integer, uuid, uuid, text, uuid, text, uuid, uuid, uuid) FROM authenticated; COMMIT; diff --git a/packages/database-jobs/revert/schemas/app_jobs/procedures/grants/grant_execute_add_scheduled_job_to_authenticated.sql b/packages/database-jobs/revert/schemas/app_jobs/procedures/grants/grant_execute_add_scheduled_job_to_authenticated.sql index d0fb9a0d1..3c0aed752 100644 --- a/packages/database-jobs/revert/schemas/app_jobs/procedures/grants/grant_execute_add_scheduled_job_to_authenticated.sql +++ b/packages/database-jobs/revert/schemas/app_jobs/procedures/grants/grant_execute_add_scheduled_job_to_authenticated.sql @@ -2,6 +2,6 @@ BEGIN; -REVOKE EXECUTE ON FUNCTION app_jobs.add_scheduled_job(text, json, json, text, text, integer, integer, uuid, uuid) FROM authenticated; +REVOKE EXECUTE ON FUNCTION app_jobs.add_scheduled_job(text, json, json, text, text, integer, integer, uuid, uuid, text, uuid, uuid, uuid) FROM authenticated; COMMIT; diff --git a/packages/database-jobs/sql/pgpm-database-jobs--0.43.2.bundle.tar.gz b/packages/database-jobs/sql/pgpm-database-jobs--0.43.2.bundle.tar.gz index 3fe8b4814..802eed768 100644 Binary files a/packages/database-jobs/sql/pgpm-database-jobs--0.43.2.bundle.tar.gz and b/packages/database-jobs/sql/pgpm-database-jobs--0.43.2.bundle.tar.gz differ diff --git a/packages/database-jobs/sql/pgpm-database-jobs--0.43.2.sql b/packages/database-jobs/sql/pgpm-database-jobs--0.43.2.sql index 89adf8e25..38f111c72 100644 --- a/packages/database-jobs/sql/pgpm-database-jobs--0.43.2.sql +++ b/packages/database-jobs/sql/pgpm-database-jobs--0.43.2.sql @@ -120,7 +120,10 @@ CREATE TABLE app_jobs.scheduled_jobs ( id bigserial PRIMARY KEY, database_id uuid NOT NULL, actor_id uuid, + principal_id uuid, entity_id uuid, + organization_id uuid, + entity_type text, queue_name text DEFAULT NULL, task_identifier text NOT NULL, payload pg_catalog.json DEFAULT '{}'::json NOT NULL, @@ -148,7 +151,13 @@ COMMENT ON COLUMN app_jobs.scheduled_jobs.database_id IS 'Database this schedule COMMENT ON COLUMN app_jobs.scheduled_jobs.actor_id IS 'User who created this scheduled job, read from JWT claims at creation time'; -COMMENT ON COLUMN app_jobs.scheduled_jobs.entity_id IS 'Entity (org/team) this scheduled job is scoped to for billing; NULL means platform-level (resolved via database_id → owner_id)'; +COMMENT ON COLUMN app_jobs.scheduled_jobs.principal_id IS 'Principal that triggered this scheduled job; equals actor_id for human-triggered jobs, differs when an agent/API-key acts on behalf of a user'; + +COMMENT ON COLUMN app_jobs.scheduled_jobs.entity_id IS 'Entity this scheduled job is attributed to for billing; read from the transaction entity claim at registration time; NULL means the claim was absent, not platform-level'; + +COMMENT ON COLUMN app_jobs.scheduled_jobs.organization_id IS 'Organization this scheduled job is attributed to; resolved from the entity pair via get_organization_id at registration time by callers that know it (e.g. data-job triggers) — never read from a claim'; + +COMMENT ON COLUMN app_jobs.scheduled_jobs.entity_type IS 'Entity type prefix (org, team, app, etc.) for interpreting entity_id'; COMMENT ON COLUMN app_jobs.scheduled_jobs.queue_name IS 'Name of the queue spawned jobs are placed into'; @@ -235,9 +244,9 @@ COMMENT ON COLUMN app_jobs.jobs.actor_id IS 'User who triggered this job, read f COMMENT ON COLUMN app_jobs.jobs.principal_id IS 'Principal that triggered this job; equals actor_id for human-triggered jobs, differs when an agent/API-key acts on behalf of a user'; -COMMENT ON COLUMN app_jobs.jobs.entity_id IS 'Entity (org/team) this job is scoped to for billing; NULL means platform-level (resolved via database_id → owner_id)'; +COMMENT ON COLUMN app_jobs.jobs.entity_id IS 'Entity this job is attributed to for billing; read from the transaction entity claim at enqueue time; NULL means the claim was absent, not platform-level'; -COMMENT ON COLUMN app_jobs.jobs.organization_id IS 'Top-level organization for this entity; resolved at enqueue time via get_organization_id(entity_type, entity_id)'; +COMMENT ON COLUMN app_jobs.jobs.organization_id IS 'Organization this job is attributed to; resolved from the entity pair via get_organization_id at enqueue time by callers that know it (e.g. data-job triggers) — never read from a claim'; COMMENT ON COLUMN app_jobs.jobs.entity_type IS 'Entity type prefix (org, team, app, etc.) for interpreting entity_id'; @@ -433,6 +442,12 @@ BEGIN END IF; END IF; + PERFORM jwt_private.assert_attribution( + sched.actor_id, + sched.entity_id, + sched.entity_type + ); + -- a job carrying this key that is already in flight covers this tick, and the -- keyed upsert below cannot refresh a locked row IF (sched.key IS NOT NULL) THEN @@ -454,7 +469,10 @@ BEGIN INSERT INTO app_jobs.jobs ( database_id, actor_id, + principal_id, entity_id, + organization_id, + entity_type, queue_name, task_identifier, payload, @@ -464,8 +482,14 @@ BEGIN ) VALUES ( sched.database_id, sched.actor_id, + sched.principal_id, sched.entity_id, - sched.queue_name, + sched.organization_id, + sched.entity_type, + -- same reading as app_jobs.add_job: the shared literal 'default' (copied + -- onto a schedule from its function definition) is not a lock domain, so a + -- tick of one schedule must not block every other job in the database. + nullif(nullif(sched.queue_name, ''), 'default'), sched.task_identifier, sched.payload, sched.priority, @@ -474,7 +498,8 @@ BEGIN ) ON CONFLICT (KEY) DO UPDATE SET - database_id = excluded.database_id, actor_id = excluded.actor_id, entity_id = excluded.entity_id, + database_id = excluded.database_id, actor_id = excluded.actor_id, principal_id = excluded.principal_id, + entity_id = excluded.entity_id, organization_id = excluded.organization_id, entity_type = excluded.entity_type, task_identifier = excluded.task_identifier, payload = excluded.payload, queue_name = excluded.queue_name, max_attempts = excluded.max_attempts, priority = excluded.priority, run_at = excluded.run_at, -- always reset error/retry state attempts = 0, last_error = NULL @@ -772,13 +797,18 @@ CREATE FUNCTION app_jobs.add_scheduled_job( queue_name text DEFAULT NULL, max_attempts int DEFAULT 25, priority int DEFAULT 0, - entity_id uuid DEFAULT NULL, - db_id uuid DEFAULT jwt_private.current_database_id() + entity_id uuid DEFAULT jwt_private.current_entity_id(), + db_id uuid DEFAULT jwt_private.require_database_id(), + entity_type text DEFAULT jwt_private.current_entity_type(), + organization_id uuid DEFAULT NULL, + actor_id uuid DEFAULT jwt_public.current_user_id(), + principal_id uuid DEFAULT jwt_public.current_principal_id() ) RETURNS app_jobs.scheduled_jobs AS $EOFCODE$ DECLARE v_job app_jobs.scheduled_jobs; v_database_id uuid; v_actor_id uuid; + v_principal_id uuid; BEGIN -- db_id defaults to the session's database claim; only callers that act on -- behalf of a different database (e.g. provisioning triggers, platform-owned @@ -787,7 +817,13 @@ BEGIN -- scheduled_jobs.database_id NOT NULL constraint rather than producing an -- unattributable job. v_database_id := db_id; - v_actor_id := jwt_public.current_user_id(); + v_actor_id := add_scheduled_job.actor_id; + v_principal_id := add_scheduled_job.principal_id; + PERFORM jwt_private.assert_attribution( + v_actor_id, + add_scheduled_job.entity_id, + add_scheduled_job.entity_type + ); IF job_key IS NOT NULL THEN @@ -795,7 +831,10 @@ BEGIN INSERT INTO app_jobs.scheduled_jobs ( database_id, actor_id, + principal_id, entity_id, + organization_id, + entity_type, task_identifier, payload, queue_name, @@ -806,7 +845,10 @@ BEGIN ) VALUES ( v_database_id, v_actor_id, + v_principal_id, add_scheduled_job.entity_id, + add_scheduled_job.organization_id, + add_scheduled_job.entity_type, identifier, coalesce(payload, '{}'::json), queue_name, @@ -817,6 +859,12 @@ BEGIN ) ON CONFLICT (key) DO UPDATE SET + database_id = EXCLUDED.database_id, + actor_id = EXCLUDED.actor_id, + principal_id = EXCLUDED.principal_id, + entity_id = EXCLUDED.entity_id, + organization_id = EXCLUDED.organization_id, + entity_type = EXCLUDED.entity_type, task_identifier = EXCLUDED.task_identifier, payload = EXCLUDED.payload, queue_name = EXCLUDED.queue_name, @@ -846,7 +894,10 @@ BEGIN INSERT INTO app_jobs.scheduled_jobs ( database_id, actor_id, + principal_id, entity_id, + organization_id, + entity_type, task_identifier, payload, queue_name, @@ -856,7 +907,10 @@ BEGIN ) VALUES ( v_database_id, v_actor_id, + v_principal_id, add_scheduled_job.entity_id, + add_scheduled_job.organization_id, + add_scheduled_job.entity_type, identifier, payload, queue_name, @@ -868,7 +922,7 @@ BEGIN END; $EOFCODE$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER; -GRANT EXECUTE ON FUNCTION app_jobs.add_scheduled_job(text, pg_catalog.json, pg_catalog.json, text, text, int, int, uuid, uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION app_jobs.add_scheduled_job(text, pg_catalog.json, pg_catalog.json, text, text, int, int, uuid, uuid, text, uuid, uuid, uuid) TO authenticated; CREATE FUNCTION app_jobs.schedule_min_interval_seconds( schedule_info pg_catalog.json @@ -953,18 +1007,21 @@ CREATE FUNCTION app_jobs.add_job( run_at timestamptz DEFAULT now(), max_attempts int DEFAULT 25, priority int DEFAULT 0, - entity_id uuid DEFAULT NULL, + entity_id uuid DEFAULT jwt_private.current_entity_id(), organization_id uuid DEFAULT NULL, - entity_type text DEFAULT NULL, + entity_type text DEFAULT jwt_private.current_entity_type(), function_definition_id uuid DEFAULT NULL, definition_scope text DEFAULT NULL, - db_id uuid DEFAULT jwt_private.current_database_id() + db_id uuid DEFAULT jwt_private.require_database_id(), + actor_id uuid DEFAULT jwt_public.current_user_id(), + principal_id uuid DEFAULT jwt_public.current_principal_id() ) RETURNS app_jobs.jobs AS $EOFCODE$ DECLARE v_job app_jobs.jobs; v_database_id uuid; v_actor_id uuid; v_principal_id uuid; + v_queue_name text; BEGIN -- db_id defaults to the session's database claim; only callers that act on -- behalf of a different database (e.g. platform-owned births) pass it @@ -972,9 +1029,20 @@ BEGIN -- session with no explicit db_id is rejected by the default expression rather -- than producing an unattributable job. v_database_id := db_id; - v_actor_id := jwt_public.current_user_id(); - - v_principal_id := jwt_public.current_principal_id(); + v_actor_id := add_job.actor_id; + v_principal_id := add_job.principal_id; + -- queue_name is a mutual-exclusion lock, never a routing label: get_job holds + -- the queue row for the whole job and claims nothing else on it, and no worker + -- selects work by queue. A name shared by unrelated jobs therefore serializes + -- all of them, and 'default' is precisely that name -- what a function + -- definition carries when its author asked for no serialization at all. Read + -- it, and the empty string, as no lock. + v_queue_name := nullif(nullif(add_job.queue_name, ''), 'default'); + PERFORM jwt_private.assert_attribution( + v_actor_id, + add_job.entity_id, + add_job.entity_type + ); IF job_key IS NOT NULL THEN -- Upsert job @@ -1005,7 +1073,7 @@ BEGIN add_job.definition_scope, identifier, coalesce(payload, '{}'::json), - queue_name, + v_queue_name, coalesce(run_at, now()), coalesce(max_attempts, 25), job_key, @@ -1071,7 +1139,7 @@ BEGIN add_job.definition_scope, identifier, payload, - queue_name, + v_queue_name, run_at, max_attempts, priority @@ -1082,7 +1150,7 @@ BEGIN END; $EOFCODE$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER; -GRANT EXECUTE ON FUNCTION app_jobs.add_job(text, pg_catalog.json, text, text, timestamptz, int, int, uuid, uuid, text, uuid, text, uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION app_jobs.add_job(text, pg_catalog.json, text, text, timestamptz, int, int, uuid, uuid, text, uuid, text, uuid, uuid, uuid) TO authenticated; CREATE FUNCTION app_jobs.remove_job( job_key text diff --git a/packages/database-jobs/verify/schemas/app_jobs/procedures/add_job.sql b/packages/database-jobs/verify/schemas/app_jobs/procedures/add_job.sql index 0c68def06..f64c63e52 100644 --- a/packages/database-jobs/verify/schemas/app_jobs/procedures/add_job.sql +++ b/packages/database-jobs/verify/schemas/app_jobs/procedures/add_job.sql @@ -2,6 +2,6 @@ BEGIN; -SELECT assert_function('app_jobs.add_job(text, json, text, text, timestamptz, int4, int4, uuid, uuid, text, uuid, text, uuid)'::regprocedure); +SELECT assert_function('app_jobs.add_job(text, json, text, text, timestamptz, int4, int4, uuid, uuid, text, uuid, text, uuid, uuid, uuid)'::regprocedure); ROLLBACK; diff --git a/packages/database-jobs/verify/schemas/app_jobs/procedures/add_scheduled_job.sql b/packages/database-jobs/verify/schemas/app_jobs/procedures/add_scheduled_job.sql index 2c12d35bb..4a288a8d2 100644 --- a/packages/database-jobs/verify/schemas/app_jobs/procedures/add_scheduled_job.sql +++ b/packages/database-jobs/verify/schemas/app_jobs/procedures/add_scheduled_job.sql @@ -2,6 +2,6 @@ BEGIN; -SELECT assert_function('app_jobs.add_scheduled_job(text, json, json, text, text, int4, int4, uuid, uuid)'::regprocedure); +SELECT assert_function('app_jobs.add_scheduled_job(text, json, json, text, text, int4, int4, uuid, uuid, text, uuid, uuid, uuid)'::regprocedure); ROLLBACK; diff --git a/packages/database-jobs/verify/schemas/app_jobs/procedures/grants/grant_execute_add_job_to_authenticated.sql b/packages/database-jobs/verify/schemas/app_jobs/procedures/grants/grant_execute_add_job_to_authenticated.sql index ebdd77854..70c4f8864 100644 --- a/packages/database-jobs/verify/schemas/app_jobs/procedures/grants/grant_execute_add_job_to_authenticated.sql +++ b/packages/database-jobs/verify/schemas/app_jobs/procedures/grants/grant_execute_add_job_to_authenticated.sql @@ -2,6 +2,6 @@ BEGIN; -SELECT assert_function_grant('app_jobs.add_job(text, json, text, text, timestamptz, integer, integer, uuid, uuid, text, uuid, text, uuid)'::regprocedure, 'authenticated', 'EXECUTE'); +SELECT assert_function_grant('app_jobs.add_job(text, json, text, text, timestamptz, integer, integer, uuid, uuid, text, uuid, text, uuid, uuid, uuid)'::regprocedure, 'authenticated', 'EXECUTE'); ROLLBACK; diff --git a/packages/database-jobs/verify/schemas/app_jobs/procedures/grants/grant_execute_add_scheduled_job_to_authenticated.sql b/packages/database-jobs/verify/schemas/app_jobs/procedures/grants/grant_execute_add_scheduled_job_to_authenticated.sql index f580b9417..d56f2cc8f 100644 --- a/packages/database-jobs/verify/schemas/app_jobs/procedures/grants/grant_execute_add_scheduled_job_to_authenticated.sql +++ b/packages/database-jobs/verify/schemas/app_jobs/procedures/grants/grant_execute_add_scheduled_job_to_authenticated.sql @@ -2,6 +2,6 @@ BEGIN; -SELECT assert_function_grant('app_jobs.add_scheduled_job(text, json, json, text, text, integer, integer, uuid, uuid)'::regprocedure, 'authenticated', 'EXECUTE'); +SELECT assert_function_grant('app_jobs.add_scheduled_job(text, json, json, text, text, integer, integer, uuid, uuid, text, uuid, uuid, uuid)'::regprocedure, 'authenticated', 'EXECUTE'); ROLLBACK; diff --git a/packages/function-resolution/__tests__/capabilities.test.ts b/packages/function-resolution/__tests__/capabilities.test.ts index c33bdac1a..759b063d0 100644 --- a/packages/function-resolution/__tests__/capabilities.test.ts +++ b/packages/function-resolution/__tests__/capabilities.test.ts @@ -170,13 +170,15 @@ describe('function-resolution capability resolution', () => { await pg.query( `INSERT INTO metaschema_modules_public.catalog_module (database_id, schema_id, functions_table_id, - domains_table_id, apis_table_id, sites_table_id, namespaces_table_id, + domains_table_id, managed_domains_table_id, + apis_table_id, sites_table_id, namespaces_table_id, resources_table_id, resource_definitions_table_id, resource_installations_table_id, apps_table_id, buckets_table_id, sites_web_config_table_id, sites_error_pages_table_id, sites_app_links_table_id, sites_deep_links_table_id, - redirects_table_id, bindings_table_id, images_table_id, scope) - VALUES ($1, $2, $3, $3, $4, $3, $3, $3, $3, $3, $3, $5, $3, $3, $3, $3, $3, $6, $3, 'database')`, + images_table_id, redirects_table_id, + app_store_identities_table_id, bindings_table_id, scope) + VALUES ($1, $2, $3, $3, $3, $3, $4, $3, $3, $3, $3, $3, $3, $5, $3, $3, $3, $3, $3, $3, $6, 'database')`, [ TENANT_DB, catFunctions.schemaId, diff --git a/packages/function-resolution/__tests__/catalog-fastpath.test.ts b/packages/function-resolution/__tests__/catalog-fastpath.test.ts index 1ed54cd9d..56f03f29e 100644 --- a/packages/function-resolution/__tests__/catalog-fastpath.test.ts +++ b/packages/function-resolution/__tests__/catalog-fastpath.test.ts @@ -195,13 +195,15 @@ describe('function-resolution catalog fast-path', () => { await pg.query( `INSERT INTO metaschema_modules_public.catalog_module (database_id, schema_id, functions_table_id, - domains_table_id, apis_table_id, sites_table_id, namespaces_table_id, + domains_table_id, managed_domains_table_id, + apis_table_id, sites_table_id, namespaces_table_id, resources_table_id, resource_definitions_table_id, resource_installations_table_id, apps_table_id, buckets_table_id, sites_web_config_table_id, sites_error_pages_table_id, sites_app_links_table_id, sites_deep_links_table_id, - redirects_table_id, bindings_table_id, images_table_id, scope) - VALUES ($1, $2, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, 'app')`, + images_table_id, redirects_table_id, + app_store_identities_table_id, bindings_table_id, scope) + VALUES ($1, $2, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, 'app')`, [TENANT_DB, catSchema.id, catTable.id] ); diff --git a/packages/function-resolution/__tests__/published-capability-catalog.test.ts b/packages/function-resolution/__tests__/published-capability-catalog.test.ts index 3b56d8b3f..fabdce162 100644 --- a/packages/function-resolution/__tests__/published-capability-catalog.test.ts +++ b/packages/function-resolution/__tests__/published-capability-catalog.test.ts @@ -237,14 +237,15 @@ describe('capability resolution against the published catalog planes', () => { await pg.query( `INSERT INTO metaschema_modules_public.catalog_module (database_id, schema_id, functions_table_id, - domains_table_id, apis_table_id, sites_table_id, namespaces_table_id, + domains_table_id, managed_domains_table_id, + apis_table_id, sites_table_id, namespaces_table_id, resources_table_id, resource_definitions_table_id, resource_installations_table_id, apps_table_id, buckets_table_id, - bindings_table_id, sites_web_config_table_id, + app_store_identities_table_id, bindings_table_id, sites_web_config_table_id, sites_error_pages_table_id, sites_app_links_table_id, sites_deep_links_table_id, - redirects_table_id, images_table_id, scope) - VALUES ($1, $2, $3, $3, $4, $3, $3, $3, $3, $3, $3, $5, $6, $3, $3, $3, $3, $3, $3, 'database')`, + images_table_id, redirects_table_id, scope) + VALUES ($1, $2, $3, $3, $3, $3, $4, $3, $3, $3, $3, $3, $3, $5, $6, $3, $3, $3, $3, $3, $3, 'database')`, [dbId, schemaId, bucketsTableId, apisTableId, bucketsTableId, bindingsTableId] ); // Label kept for readability of the fixture rows above. diff --git a/packages/function-resolution/__tests__/published-catalog.test.ts b/packages/function-resolution/__tests__/published-catalog.test.ts index b491689d7..32fc17c30 100644 --- a/packages/function-resolution/__tests__/published-catalog.test.ts +++ b/packages/function-resolution/__tests__/published-catalog.test.ts @@ -142,13 +142,15 @@ describe('function-resolution against the published catalog plane', () => { await pg.query( `INSERT INTO metaschema_modules_public.catalog_module (database_id, schema_id, functions_table_id, - domains_table_id, apis_table_id, sites_table_id, namespaces_table_id, + domains_table_id, managed_domains_table_id, + apis_table_id, sites_table_id, namespaces_table_id, resources_table_id, resource_definitions_table_id, resource_installations_table_id, apps_table_id, buckets_table_id, sites_web_config_table_id, sites_error_pages_table_id, sites_app_links_table_id, sites_deep_links_table_id, - redirects_table_id, bindings_table_id, images_table_id, scope) - VALUES ($1, $2, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, 'app')`, + images_table_id, redirects_table_id, + app_store_identities_table_id, bindings_table_id, scope) + VALUES ($1, $2, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, 'app')`, [dbId, schemaId, tableId] ); diff --git a/packages/function-resolution/__tests__/resolution.test.ts b/packages/function-resolution/__tests__/resolution.test.ts index 6fdaf4ddf..a64984563 100644 --- a/packages/function-resolution/__tests__/resolution.test.ts +++ b/packages/function-resolution/__tests__/resolution.test.ts @@ -8,6 +8,7 @@ const PLATFORM_DB = '11111111-1111-1111-1111-111111111111'; const TENANT_DB = '22222222-2222-2222-2222-222222222222'; const ORG_ID = '33333333-3333-3333-3333-333333333333'; const PLATFORM_ORG_ID = '44444444-4444-4444-4444-444444444444'; +const ACTOR_ID = '55555555-5555-5555-5555-555555555555'; // Ids captured during seeding. const ids: Record = {}; @@ -155,13 +156,15 @@ describe('function-resolution end-to-end (format-based, no AST)', () => { await pg.query( `INSERT INTO metaschema_modules_public.catalog_module (database_id, schema_id, functions_table_id, - domains_table_id, apis_table_id, sites_table_id, namespaces_table_id, + domains_table_id, managed_domains_table_id, + apis_table_id, sites_table_id, namespaces_table_id, resources_table_id, resource_definitions_table_id, resource_installations_table_id, apps_table_id, buckets_table_id, sites_web_config_table_id, sites_error_pages_table_id, sites_app_links_table_id, sites_deep_links_table_id, - redirects_table_id, bindings_table_id, images_table_id, scope) - VALUES ($1, $2, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, 'app')`, + images_table_id, redirects_table_id, + app_store_identities_table_id, bindings_table_id, scope) + VALUES ($1, $2, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, $3, 'app')`, [TENANT_DB, catSchema.id, catTable.id] ); }); @@ -323,10 +326,17 @@ describe('function-resolution end-to-end (format-based, no AST)', () => { }); it('enqueue(): full portable path resolves, routes, and inserts a job', async () => { - // enqueue reads the execution database from jwt_private.current_database_id() + // enqueue reads the execution database from jwt_private.require_database_id() // (the jwt.claims.database_id GUC), set transaction-locally around the call. + // app_jobs.add_job attributes the job from the same claims, and refuses a job + // with neither an actor nor an entity, so an actor claim is part of the frame. await pg.begin(); - await pg.query(`SELECT set_config('jwt.claims.database_id', $1, true)`, [TENANT_DB]); + await pg.query( + `SELECT set_config('jwt.claims.database_id', $1, true), + set_config('jwt.claims.user_id', $2, true), + set_config('jwt.claims.principal_id', $2, true)`, + [TENANT_DB, ACTOR_ID] + ); const job = await pg.one( `SELECT * FROM function_resolution.enqueue( task_identifier := 'email:send', @@ -346,7 +356,12 @@ describe('function-resolution end-to-end (format-based, no AST)', () => { it('enqueue(): NULL scope is a hard error', async () => { await pg.begin(); - await pg.query(`SELECT set_config('jwt.claims.database_id', $1, true)`, [TENANT_DB]); + await pg.query( + `SELECT set_config('jwt.claims.database_id', $1, true), + set_config('jwt.claims.user_id', $2, true), + set_config('jwt.claims.principal_id', $2, true)`, + [TENANT_DB, ACTOR_ID] + ); await expect( pg.one(`SELECT * FROM function_resolution.enqueue(task_identifier := 'email:send')`) ).rejects.toThrow(/ENQUEUE_SCOPE_REQUIRED/); diff --git a/packages/function-resolution/deploy/schemas/function_resolution/procedures/enqueue.sql b/packages/function-resolution/deploy/schemas/function_resolution/procedures/enqueue.sql index 711691c7b..665c8ee41 100644 --- a/packages/function-resolution/deploy/schemas/function_resolution/procedures/enqueue.sql +++ b/packages/function-resolution/deploy/schemas/function_resolution/procedures/enqueue.sql @@ -4,7 +4,9 @@ -- requires: schemas/function_resolution/procedures/routing -- requires: pgpm-app-scope:schemas/app_scope/procedures/frames -- requires: pgpm-database-jobs:schemas/app_jobs/procedures/add_job --- requires: pgpm-jwt-claims:schemas/jwt_private/procedures/current_database_id +-- requires: pgpm-jwt-claims:schemas/jwt_private/procedures/require_database_id +-- requires: pgpm-jwt-claims:schemas/jwt_public/procedures/current_user_id +-- requires: pgpm-jwt-claims:schemas/jwt_public/procedures/current_principal_id BEGIN; @@ -62,7 +64,9 @@ CREATE FUNCTION function_resolution.enqueue( should_resolve boolean DEFAULT true, resolution_scope text DEFAULT NULL, resolution_key uuid DEFAULT NULL, - db_id uuid DEFAULT jwt_private.current_database_id() + db_id uuid DEFAULT jwt_private.require_database_id(), + actor_id uuid DEFAULT jwt_public.current_user_id(), + principal_id uuid DEFAULT jwt_public.current_principal_id() ) RETURNS app_jobs.jobs AS $$ DECLARE v_database_id uuid; @@ -143,12 +147,14 @@ BEGIN entity_type := entity_type, function_definition_id := v_fn_id, definition_scope := v_def_scope, - db_id := v_database_id + db_id := v_database_id, + actor_id := enqueue.actor_id, + principal_id := enqueue.principal_id ); END; $$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER; -COMMENT ON FUNCTION function_resolution.enqueue(text, json, text, uuid, uuid, text, text, text, timestamptz, integer, integer, uuid, text, boolean, text, uuid, uuid) IS +COMMENT ON FUNCTION function_resolution.enqueue(text, json, text, uuid, uuid, text, text, text, timestamptz, integer, integer, uuid, text, boolean, text, uuid, uuid, uuid, uuid) IS 'Resolver-aware job enqueue: resolves (or trusts a supplied) function definition for the execution (database, scope, entity, task_identifier), stamps the (function_definition_id, definition_scope) pair and the definition''s queue routing, then delegates the insert to app_jobs.add_job. The single enqueue path for function jobs; definition-less tasks enqueue with a NULL pair. Portable: built only on app_scope + the metaschema catalog + app_jobs, no AST/deparser runtime.'; COMMIT; diff --git a/packages/function-resolution/deploy/schemas/function_resolution/procedures/install_mantra.sql b/packages/function-resolution/deploy/schemas/function_resolution/procedures/install_mantra.sql new file mode 100644 index 000000000..139c7f840 --- /dev/null +++ b/packages/function-resolution/deploy/schemas/function_resolution/procedures/install_mantra.sql @@ -0,0 +1,107 @@ +-- Deploy schemas/function_resolution/procedures/install_mantra to pg +-- requires: schemas/function_resolution/schema +-- requires: schemas/function_resolution/procedures/install_route_bindings + +BEGIN; + +-- install_mantra: install the platform's Mantra page set onto one site. +-- +-- Mantra is not a new site backing and not a new routing mode: every page it +-- serves is an ordinary route row whose target is a function +-- (target_function_id -> the functions catalog), which the routing resolver +-- already answers on the function lane, emitting the definition's +-- task_identifier for the sync gateway. +-- +-- So there is nothing Mantra-specific left in the install itself, and this is +-- now the narrowest possible wrapper over the general engine +-- (install_route_bindings): it holds the Mantra document contract — a JSON array +-- of {path, task_identifier}, which the generated verb reads from the platform's +-- own preset catalog (metaschema_generators.content_preset_definition of kind +-- 'route_bindings', slug 'mantra' by default) — and declares every entry a +-- function target. Everything else (the one scope read off the sites plane's own +-- registration, the routes plane, the ownership key, resolution, idempotency) +-- belongs to the engine, so the platform's page set and a deployment graph +-- binding its own paths cannot drift apart. +-- +-- WHICH paths get installed stays data, and it is not this module's data: the +-- caller passes the document. That keeps this module portable (it depends on +-- app-scope and the module registry, not on the generator layer that owns the +-- catalog) and keeps the page set a versioned row a deployment can fork, never a +-- list inside a function. +-- +-- The sites plane arrives BY REFERENCE, as a regclass rather than a pair of name +-- strings. This entry point is called from GENERATED SQL, and a generated body +-- that spelled its own schema in a bare string literal carried the physical +-- schema name of the database it was generated in: the platform export renames +-- schemas to their logical names through an AST rewrite, which routes a reg* +-- cast structurally and deliberately leaves a bare literal alone, so the baked +-- name survived into the published module and matched no registration. It is the +-- same reason every generated verify asserts through a reg* cast. +CREATE FUNCTION function_resolution.install_mantra( + database_id uuid, + sites_plane regclass, + site_id uuid, + bindings jsonb, + entity_id uuid DEFAULT NULL +) RETURNS jsonb AS $$ +DECLARE + function_bindings jsonb; + sites_schema text; + sites_table text; +BEGIN + IF install_mantra.sites_plane IS NULL THEN + RAISE EXCEPTION 'MANTRA_SITES_PLANE_REQUIRED: sites_plane is required' + USING ERRCODE = 'FR050'; + END IF; + + -- The plane's names, read off the catalog entry the reference itself proves + -- exists, and handed to the engine as the registry records them. + SELECT n.nspname, c.relname + INTO sites_schema, sites_table + FROM pg_catalog.pg_class AS c + JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace + WHERE c.oid = install_mantra.sites_plane; + + -- The Mantra document contract, checked here because it is stricter than the + -- engine's: every entry names a task, and none of them may name a target + -- kind — a preset row that has grown a service binding is a broken preset, + -- not a mixed install. + IF install_mantra.bindings IS NULL + OR jsonb_typeof(install_mantra.bindings) <> 'array' + OR jsonb_array_length(install_mantra.bindings) = 0 THEN + RAISE EXCEPTION 'MANTRA_BINDINGS_INVALID: bindings must be a non-empty JSON array of {path, task_identifier}, got %', + coalesce(jsonb_typeof(install_mantra.bindings), 'null') + USING ERRCODE = 'FR060'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM jsonb_array_elements(install_mantra.bindings) AS b + WHERE jsonb_typeof(b) <> 'object' + OR coalesce(b ->> 'path', '') = '' + OR coalesce(b ->> 'task_identifier', '') = '' + OR b ? 'target' + ) THEN + RAISE EXCEPTION 'MANTRA_BINDINGS_INVALID: every binding must carry a non-empty path and task_identifier, and no target kind' + USING ERRCODE = 'FR060'; + END IF; + + SELECT jsonb_agg(b || jsonb_build_object('target', 'function')) + INTO function_bindings + FROM jsonb_array_elements(install_mantra.bindings) AS b; + + RETURN function_resolution.install_route_bindings( + install_mantra.database_id, + sites_schema, + sites_table, + install_mantra.site_id, + function_bindings, + install_mantra.entity_id + ); +END; +$$ LANGUAGE plpgsql VOLATILE; + +COMMENT ON FUNCTION function_resolution.install_mantra(uuid, regclass, uuid, jsonb, uuid) IS +'Install the Mantra page set (a JSON array of {path, task_identifier}, which the generated verb reads from the content_presets catalog at kind ''route_bindings'') onto one site as ordinary function-target routes. The sites plane arrives by reference as a regclass, so a generated caller never spells a schema name in a bare string literal the platform export''s AST rename cannot follow. A thin wrapper holding the Mantra document contract — every entry names a task, none names a target kind — over function_resolution.install_route_bindings, which owns the one-scope install: scope and ownership key read from the sites plane''s own registration, the routes plane from app_scope.routing_tables, resolution at that same (scope, entity), idempotent per (domain_id, path).'; + +COMMIT; diff --git a/packages/function-resolution/deploy/schemas/function_resolution/procedures/install_route_bindings.sql b/packages/function-resolution/deploy/schemas/function_resolution/procedures/install_route_bindings.sql new file mode 100644 index 000000000..0a7489182 --- /dev/null +++ b/packages/function-resolution/deploy/schemas/function_resolution/procedures/install_route_bindings.sql @@ -0,0 +1,506 @@ +-- Deploy schemas/function_resolution/procedures/install_route_bindings to pg +-- requires: schemas/function_resolution/schema +-- requires: schemas/function_resolution/procedures/resolve +-- requires: metaschema-modules:schemas/metaschema_modules_public/tables/site_surface_module/table +-- requires: metaschema-modules:schemas/metaschema_modules_public/tables/route_module/table +-- requires: metaschema-modules:schemas/metaschema_modules_public/tables/resource_module/table +-- requires: pgpm-app-scope:schemas/app_scope/procedures/routing_tables + +BEGIN; + +-- install_route_bindings: install a set of route bindings onto one site, at ONE +-- scope, for ONE entity. +-- +-- This is the one engine behind every "point these paths at what we just +-- deployed" verb: the platform's own Mantra page set (function targets, through +-- function_resolution.install_mantra) and a deployment graph binding the paths +-- of a service it just released (service targets). A binding is ordinary route +-- data either way — a row in the scope's routes plane whose typed target column +-- carries the id the routing resolver already answers — so this is a pure data +-- write and there is nothing to reconcile here: the routes plane's own job +-- trigger enqueues http_route:reconcile. +-- +-- The bindings document is a JSON array whose every entry NAMES ITS TARGET KIND: +-- +-- {"path": "/login", "target": "function", "task_identifier": "mantra:signin"} +-- {"path": "/app", "target": "service", "service_id": ""} +-- +-- The kind is never inferred from which key happens to be present, and an entry +-- carrying keys for two kinds is a malformed document rather than a precedence +-- question: guessing is how a deployment silently binds /app to the wrong plane. +-- +-- The single-scope invariant, unchanged from the function-only engine it +-- generalises. Nothing here takes a scope literal from a caller or bakes one +-- into generated SQL. The caller names the physical sites plane it belongs to; +-- the scope and the ownership key column are then read off that plane's own +-- module registration (site_surface_module.scope / .entity_field, stamped at +-- provisioning time by metaschema_generators.scope_key_column), and that one +-- (scope, key value) is what every subsequent step uses: +-- +-- * app_scope.routing_tables(database_id, scope) names the routes plane +-- serving that scope — so the routes written are the ones that scope's +-- resolver reads, never another frame's plane; +-- * the site row and its existing route are read pinned to the key value, so +-- a SECURITY DEFINER wrapper cannot reach another tenant's or another +-- entity's site; +-- * function_resolution.resolve starts at that same (scope, entity), so a +-- nearer frame publishing a task shadows an outer one FOR THAT ENTITY and +-- nobody else; +-- * a service target is proved to exist in the SAME-SCOPE resources plane — +-- the one this database's resource_module registers AT THAT SCOPE, which is +-- the source plane route_module gives target_service_id its FK against — +-- pinned to that plane's own recorded key, so a binding cannot point a +-- route at another tenant's or another entity's service; +-- * every inserted route stamps that same key value. +-- +-- The serving site is stamped the same registration-driven way. A routing plane +-- may carry a column saying which site's surface a route renders as, and its +-- route_module registration records that column's name (serving_site_field, +-- stamped by the generator that created the field). When it is recorded, every +-- route this install touches — inserted or already present, since a path the +-- document also claims is still a path this site serves — carries the site the +-- bindings were installed onto; when it is not, nothing is stamped. So the fact +-- travels with the plane rather than living in whatever verb happens to call +-- this, and this module still knows no column name of its own. +-- +-- Ownership tiers, from the plane's recorded entity_field: +-- entity_field IS NULL global tier — ownerless rows, no key stamped +-- entity_field = database_id database tier — keyed by the executing database +-- entity_field = _id entity tier — keyed by the entity_id argument, +-- which is then required +-- +-- Idempotent by construction: each insert carries a NOT EXISTS guard on the +-- route's (domain_id, path) key, so re-running after the document grows installs +-- only what is missing and never repoints a route a tenant has since edited. +-- +-- Fail-loud throughout: an unregistered plane, a scope with no routes plane, a +-- missing entity key, an unknown site, a site with no route to layer onto, a +-- malformed bindings document, an unknown or ambiguous target kind, an +-- unresolvable service, and an unpublished task each raise. Nothing is skipped +-- silently. +-- +-- The dynamic SQL is the same case as resolve_api's: the relation is named by +-- the caller's plane and proved to exist by an ordinary catalog join first, and +-- no value is ever interpolated — only verified relation and column names. +CREATE FUNCTION function_resolution.install_route_bindings( + database_id uuid, + sites_schema text, + sites_table text, + site_id uuid, + bindings jsonb, + entity_id uuid DEFAULT NULL +) RETURNS jsonb AS $$ +DECLARE + -- plane_scope, not scope: the module registrations this reads all carry a + -- scope column, and a local named scope makes every one of those predicates + -- ambiguous. + plane_scope text; + sites_key text; + routes_schema text; + routes_table text; + routes_key text; + -- The routes column carrying the serving site, from the plane's own + -- registration. NULL for a plane that has none, which makes the stamp a + -- no-op rather than an error. + routes_serving_site_key text; + stamped int := 0; + -- The same-scope resources plane a service binding must resolve within, and + -- its own recorded ownership key. NULL when this scope carries no resources + -- plane, which makes a service binding a hard error rather than an + -- unchecked insert. + resources_schema text; + resources_table text; + resources_key text; + -- The one ownership key value the whole install is keyed by (NULL at the + -- global tier), and the entity the resolver starts its frame walk at. + key_value uuid; + resolution_entity uuid; + site_found boolean; + domain_id uuid; + -- Per-binding state: the entry, its declared kind, and the id the route's + -- typed target column gets, together with the column that carries it. + entry jsonb; + entry_path text; + entry_target text; + entry_task text; + entry_service uuid; + target_column text; + target_id uuid; + service_found boolean; + inserted int; + installed jsonb := '[]'::jsonb; + skipped jsonb := '[]'::jsonb; + query text; +BEGIN + IF install_route_bindings.database_id IS NULL THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_DATABASE_REQUIRED: database_id is required' + USING ERRCODE = 'FR050'; + END IF; + + IF install_route_bindings.sites_schema IS NULL OR install_route_bindings.sites_table IS NULL THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_SITES_PLANE_REQUIRED: sites_schema and sites_table are required' + USING ERRCODE = 'FR050'; + END IF; + + IF install_route_bindings.site_id IS NULL THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_SITE_REQUIRED: site_id is required' + USING ERRCODE = 'FR050'; + END IF; + + -- The bindings document, validated before anything is written: an empty or + -- wrongly-shaped document is a broken install, not a no-op. + IF install_route_bindings.bindings IS NULL + OR jsonb_typeof(install_route_bindings.bindings) <> 'array' + OR jsonb_array_length(install_route_bindings.bindings) = 0 THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_INVALID: bindings must be a non-empty JSON array of {path, target, …}, got %', + coalesce(jsonb_typeof(install_route_bindings.bindings), 'null') + USING ERRCODE = 'FR060'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM jsonb_array_elements(install_route_bindings.bindings) AS b + WHERE jsonb_typeof(b) <> 'object' + OR coalesce(b ->> 'path', '') = '' + OR coalesce(b ->> 'target', '') = '' + ) THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_INVALID: every binding must carry a non-empty path and target' + USING ERRCODE = 'FR060'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM jsonb_array_elements(install_route_bindings.bindings) AS b + WHERE (b ->> 'target') NOT IN ('function', 'service') + ) THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_TARGET_UNKNOWN: target must be "function" or "service"' + USING ERRCODE = 'FR060'; + END IF; + + -- A binding carrying the key of a kind it did not declare is ambiguous, and + -- resolving it by precedence would let a typo repoint a path at a different + -- plane than the document reads like. + IF EXISTS ( + SELECT 1 + FROM jsonb_array_elements(install_route_bindings.bindings) AS b + WHERE (b ? 'task_identifier') AND (b ? 'service_id') + OR (b ->> 'target') = 'function' AND (b ? 'service_id') + OR (b ->> 'target') = 'service' AND (b ? 'task_identifier') + ) THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_TARGET_AMBIGUOUS: a binding must carry only the key of the target kind it declares' + USING ERRCODE = 'FR060'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM jsonb_array_elements(install_route_bindings.bindings) AS b + WHERE (b ->> 'target') = 'function' AND coalesce(b ->> 'task_identifier', '') = '' + OR (b ->> 'target') = 'service' AND coalesce(b ->> 'service_id', '') = '' + ) THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_INVALID: a function binding needs a task_identifier and a service binding needs a service_id' + USING ERRCODE = 'FR060'; + END IF; + + -- A service_id is cast to uuid before it is looked up, and a bare cast + -- failure reports "invalid input syntax for type uuid" with no hint of which + -- binding carried it — so the document is rejected by name instead. + IF EXISTS ( + SELECT 1 + FROM jsonb_array_elements(install_route_bindings.bindings) AS b + WHERE (b ->> 'target') = 'service' + AND (b ->> 'service_id') !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + ) THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_SERVICE_INVALID: a service binding''s service_id must be a uuid' + USING ERRCODE = 'FR060'; + END IF; + + -- Idempotency is keyed by (domain_id, path), so two entries claiming one + -- path would install the first and report the second as already-there — + -- a document that reads like it bound both. It is malformed instead. + IF EXISTS ( + SELECT 1 + FROM jsonb_array_elements(install_route_bindings.bindings) AS b + GROUP BY b ->> 'path' + HAVING count(*) > 1 + ) THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_PATH_DUPLICATED: a path may be bound once per document' + USING ERRCODE = 'FR060'; + END IF; + + -- 1. The plane's own registration decides the scope. This is the whole + -- point: the scope is a recorded fact about the sites plane being + -- installed onto, not a caller-supplied string and not a literal frozen + -- into a generated body. + -- + -- The registration is looked up ALONG THE CALLER'S FRAMES, the same walk + -- step 2 resolves the routes plane with, because a sites plane serving a + -- scope is not necessarily registered in the database consuming it: the + -- database-scope serving planes are shared, hosted by an outer frame's + -- database and keyed per tenant, so a tenant installing onto the plane it + -- is actually served by has no registration of its own to find. Keying is + -- unaffected — the scope comes from the registration and the key value from + -- the caller (step 3), so a tenant's rows stay stamped with the tenant. + -- Nearest frame wins, so a plane a database does register still resolves to + -- its own registration. + SELECT sm.scope, sm.entity_field + INTO plane_scope, sites_key + FROM app_scope.frames(install_route_bindings.database_id, 'database') WITH ORDINALITY AS f + JOIN metaschema_modules_public.site_surface_module AS sm + ON sm.scope = f.scope + AND sm.database_id = f.lookup_database_id + JOIN metaschema_public."table" AS t ON t.id = sm.sites_table_id + JOIN metaschema_public.schema AS s ON s.id = t.schema_id + WHERE s.schema_name = install_route_bindings.sites_schema + AND t.name = install_route_bindings.sites_table + ORDER BY f.ordinality + LIMIT 1; + + IF plane_scope IS NULL THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_SITES_PLANE_NOT_REGISTERED: no site_surface_module on any frame of database % registers %.% as its sites plane', + install_route_bindings.database_id, install_route_bindings.sites_schema, install_route_bindings.sites_table + USING ERRCODE = 'FR051'; + END IF; + + -- 2. The routes plane serving THAT scope, resolved the one way every + -- scope-aware consumer resolves it. + SELECT r.routes_schema, r.routes_table + INTO routes_schema, routes_table + FROM app_scope.routing_tables(install_route_bindings.database_id, plane_scope) AS r; + + IF routes_schema IS NULL OR routes_table IS NULL THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_ROUTES_PLANE_NOT_FOUND: scope "%" has no routes plane provisioned on any frame of database %', + plane_scope, install_route_bindings.database_id + USING ERRCODE = 'FR051'; + END IF; + + -- The routes plane's own registration carries its ownership key and, when + -- the plane has one, the column naming the site a route renders as. + SELECT rm.entity_field, rm.serving_site_field + INTO routes_key, routes_serving_site_key + FROM metaschema_modules_public.route_module AS rm + JOIN metaschema_public."table" AS t ON t.id = rm.routes_table_id + JOIN metaschema_public.schema AS s ON s.id = t.schema_id + WHERE s.schema_name = routes_schema + AND t.name = routes_table; + + -- Both planes are at the same scope, so scope_key_column gave them the same + -- ownership key. A mismatch means the registrations disagree about who owns + -- the rows, which is exactly the cross-scope write this function exists to + -- make impossible. + IF routes_key IS DISTINCT FROM sites_key THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_PLANE_KEY_MISMATCH: sites plane %.% is keyed by % but routes plane %.% is keyed by %', + install_route_bindings.sites_schema, install_route_bindings.sites_table, coalesce(sites_key, ''), + routes_schema, routes_table, coalesce(routes_key, '') + USING ERRCODE = 'FR051'; + END IF; + + -- The resources plane serving that same scope, from its own module + -- registration: target_service_id FKs this scope's source resources table, + -- so this is the plane the constraint would check a service binding against. + SELECT s.schema_name, t.name, rem.entity_field + INTO resources_schema, resources_table, resources_key + FROM metaschema_modules_public.resource_module AS rem + JOIN metaschema_public."table" AS t ON t.id = rem.resources_table_id + JOIN metaschema_public.schema AS s ON s.id = t.schema_id + WHERE rem.database_id = install_route_bindings.database_id + AND rem.scope = plane_scope; + + -- 3. One key value for the whole operation. + IF sites_key IS NULL THEN + key_value := NULL; + resolution_entity := NULL; + ELSIF sites_key = 'database_id' THEN + key_value := install_route_bindings.database_id; + resolution_entity := NULL; + ELSE + IF install_route_bindings.entity_id IS NULL THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_ENTITY_REQUIRED: scope "%" is keyed by %, so entity_id is required', + plane_scope, sites_key + USING ERRCODE = 'FR052'; + END IF; + key_value := install_route_bindings.entity_id; + resolution_entity := install_route_bindings.entity_id; + END IF; + + -- 4. The site, pinned to that key value. + -- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: the sites plane is named by the caller and proved by the registration join above + query := format( + 'SELECT EXISTS (SELECT 1 FROM %I.%I AS s WHERE s.id = $1%s)', + install_route_bindings.sites_schema, + install_route_bindings.sites_table, + CASE WHEN sites_key IS NULL + THEN ' AND $2 IS NULL' + ELSE format(' AND s.%I = $2', sites_key) + END + ); + + EXECUTE query INTO site_found USING install_route_bindings.site_id, key_value; + + IF NOT site_found THEN + RAISE EXCEPTION 'SITE_NOT_FOUND: no site % in %.% owned by %', + install_route_bindings.site_id, install_route_bindings.sites_schema, install_route_bindings.sites_table, + coalesce(key_value::text, plane_scope) + USING ERRCODE = 'FR053'; + END IF; + + -- 5. The hostname the site already serves. Bindings layer onto an existing + -- site route rather than claiming a hostname of their own: the root-route + -- guard auto-creates '/' carrying the target of a hostname's FIRST route, + -- so installing onto a bare hostname would make '/' the first binding's + -- target. A site that is not routed yet is a hard error, not a silently + -- empty install. + -- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: the routes plane is named by app_scope.routing_tables + query := format( + 'SELECT r.domain_id FROM %I.%I AS r WHERE r.target_site_id = $1%s ORDER BY r.path LIMIT 1', + routes_schema, + routes_table, + CASE WHEN routes_key IS NULL + THEN ' AND $2 IS NULL' + ELSE format(' AND r.%I = $2', routes_key) + END + ); + + EXECUTE query INTO domain_id USING install_route_bindings.site_id, key_value; + + IF domain_id IS NULL THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_SITE_NOT_ROUTED: site % serves no hostname in %.%, so there is nowhere to install the bindings', + install_route_bindings.site_id, routes_schema, routes_table + USING ERRCODE = 'FR054'; + END IF; + + -- 6. Per binding: resolve the declared target at THIS (scope, entity) and + -- install the route if it is not already there. + FOR entry IN SELECT * FROM jsonb_array_elements(install_route_bindings.bindings) + LOOP + entry_path := entry ->> 'path'; + entry_target := entry ->> 'target'; + + IF entry_target = 'function' THEN + entry_task := entry ->> 'task_identifier'; + + -- Fail-loud by the resolver's own contract: an unpublished task + -- raises FUNCTION_DEFINITION_NOT_FOUND rather than installing a + -- route that would 404 at request time. + SELECT fr.function_definition_id + INTO target_id + FROM function_resolution.resolve( + install_route_bindings.database_id, + plane_scope, + resolution_entity, + entry_task + ) AS fr; + + target_column := 'target_function_id'; + ELSE + entry_service := (entry ->> 'service_id')::uuid; + + IF resources_schema IS NULL THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_SERVICE_PLANE_NOT_FOUND: database % has no resource_module at scope "%", so there is no plane a service target could live in', + install_route_bindings.database_id, plane_scope + USING ERRCODE = 'FR055'; + END IF; + + -- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: the resources plane is named by this scope's own resource_module registration + query := format( + 'SELECT EXISTS (SELECT 1 FROM %I.%I AS r WHERE r.id = $1%s)', + resources_schema, + resources_table, + CASE WHEN resources_key IS NULL + THEN ' AND $2 IS NULL' + ELSE format(' AND r.%I = $2', resources_key) + END + ); + + EXECUTE query INTO service_found USING entry_service, key_value; + + IF NOT service_found THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_SERVICE_NOT_FOUND: no service % in %.% owned by %', + entry_service, resources_schema, resources_table, + coalesce(key_value::text, plane_scope) + USING ERRCODE = 'FR056'; + END IF; + + target_id := entry_service; + target_column := 'target_service_id'; + END IF; + + -- pgsql-lint-disable-next-line no-dynamic-sql -- write-only: insert into the routes plane named by app_scope.routing_tables; every value is a bound parameter + query := format( + 'INSERT INTO %I.%I (%s%sdomain_id, path, %I) + SELECT %s%s$1, $2, $3 + WHERE NOT EXISTS ( + SELECT 1 FROM %I.%I AS x + WHERE x.domain_id = $1 AND x.path = $2%s)', + routes_schema, + routes_table, + CASE WHEN routes_key IS NULL THEN '' ELSE format('%I, ', routes_key) END, + CASE WHEN routes_serving_site_key IS NULL THEN '' ELSE format('%I, ', routes_serving_site_key) END, + target_column, + CASE WHEN routes_key IS NULL THEN '' ELSE '$4, ' END, + CASE WHEN routes_serving_site_key IS NULL THEN '' ELSE '$5, ' END, + routes_schema, + routes_table, + CASE WHEN routes_key IS NULL + THEN ' AND $4 IS NULL' + ELSE format(' AND x.%I = $4', routes_key) + END + ); + + EXECUTE query USING domain_id, entry_path, target_id, key_value, + install_route_bindings.site_id; + GET DIAGNOSTICS inserted = ROW_COUNT; + + IF inserted > 0 THEN + installed := installed || jsonb_build_array(entry_path); + ELSE + skipped := skipped || jsonb_build_array(entry_path); + END IF; + END LOOP; + + -- Backfill the serving site onto the paths that were already there: the + -- insert above stamps what it writes, and a route the document claims but + -- did not create is still a route this site serves. IS DISTINCT FROM keeps a + -- repeat install a no-op write rather than a row version per path, and the + -- ownership pin repeats here so a SECURITY DEFINER caller cannot stamp a row + -- another owner holds even if a domain were ever shared across planes. + IF routes_serving_site_key IS NOT NULL AND jsonb_array_length(skipped) > 0 THEN + -- pgsql-lint-disable-next-line no-dynamic-sql -- write-only: update the routes plane named by app_scope.routing_tables; every value is a bound parameter + query := format( + 'UPDATE %I.%I AS r + SET %I = $1 + WHERE r.domain_id = $2 + AND $3 @> to_jsonb(r.path) + AND r.%I IS DISTINCT FROM $1%s', + routes_schema, + routes_table, + routes_serving_site_key, + routes_serving_site_key, + CASE WHEN routes_key IS NULL + THEN ' AND $4 IS NULL' + ELSE format(' AND r.%I = $4', routes_key) + END + ); + + EXECUTE query USING install_route_bindings.site_id, domain_id, skipped, key_value; + GET DIAGNOSTICS stamped = ROW_COUNT; + END IF; + + RETURN jsonb_build_object( + 'site_id', install_route_bindings.site_id, + 'scope', plane_scope, + 'entity_id', install_route_bindings.entity_id, + 'domain_id', domain_id, + 'routes_schema', routes_schema, + 'routes_table', routes_table, + 'installed', installed, + 'skipped', skipped, + 'serving_site_field', routes_serving_site_key, + 'serving_site_backfilled', stamped + ); +END; +$$ LANGUAGE plpgsql VOLATILE; + +COMMENT ON FUNCTION function_resolution.install_route_bindings(uuid, text, text, uuid, jsonb, uuid) IS +'Install a set of route bindings — a JSON array of {path, target, …} entries, each NAMING its target kind ("function" with a task_identifier, or "service" with a service_id) — onto one site as ordinary route rows, at ONE scope for ONE entity. The scope and ownership key are read from the named sites plane''s own site_surface_module registration, located along the caller''s frames (nearest first) so a shared serving plane hosted by an outer frame''s database resolves for the tenant consuming it — never a caller-supplied or generated literal — and that one (scope, key) then names the routes plane (app_scope.routing_tables), pins the site, route and service reads, starts function_resolution.resolve''s frame walk, and stamps every inserted row; a service target is proved to exist in the same-scope resources plane the routes plane''s registration records, which is the plane target_service_id FKs. Idempotent per (domain_id, path); raises on a malformed document, an unknown or ambiguous target kind, an unregistered plane, a scope with no routes plane, a missing entity key, an unknown site, an unrouted site, a scope with no resources plane, an unresolvable service, or an unpublished task.'; + +COMMIT; diff --git a/packages/function-resolution/pgpm.plan b/packages/function-resolution/pgpm.plan index cce588290..abe69283e 100644 --- a/packages/function-resolution/pgpm.plan +++ b/packages/function-resolution/pgpm.plan @@ -7,7 +7,7 @@ schemas/function_resolution/procedures/definitions_location [schemas/function_re schemas/function_resolution/procedures/routing [schemas/function_resolution/schema] 2017-08-11T08:11:51Z constructive # queue routing for a definition schemas/function_resolution/procedures/resolve [schemas/function_resolution/schema] 2017-08-11T08:11:51Z constructive # cross-scope resolver schemas/function_resolution/procedures/resolve_invocation [schemas/function_resolution/schema schemas/function_resolution/procedures/resolve] 2017-08-11T08:11:51Z constructive # invocation-lane resolver -schemas/function_resolution/procedures/enqueue [schemas/function_resolution/schema schemas/function_resolution/procedures/routing schemas/function_resolution/procedures/resolve] 2017-08-11T08:11:51Z constructive # resolver-aware enqueue entry point +schemas/function_resolution/procedures/enqueue [schemas/function_resolution/schema schemas/function_resolution/procedures/routing schemas/function_resolution/procedures/resolve pgpm-jwt-claims:schemas/jwt_private/procedures/require_database_id] 2017-08-11T08:11:51Z constructive # resolver-aware enqueue entry point schemas/function_resolution/procedures/frame_candidates [schemas/function_resolution/schema] 2017-08-11T08:11:51Z constructive # frames expanded into catalog probe candidates schemas/function_resolution/procedures/bucket_matches [schemas/function_resolution/schema schemas/function_resolution/procedures/frame_candidates] 2017-08-11T08:11:51Z constructive # buckets a selector matches in the nearest frame schemas/function_resolution/procedures/resolve_bucket [schemas/function_resolution/schema schemas/function_resolution/procedures/frame_candidates schemas/function_resolution/procedures/bucket_matches] 2017-08-11T08:11:51Z constructive # bucket selector {tags,type} resolution @@ -23,3 +23,5 @@ schemas/function_resolution/procedures/resolve_payload_refs [schemas/function_re schemas/function_resolution/procedures/resolve_capabilities [schemas/function_resolution/schema schemas/function_resolution/procedures/definitions_location schemas/function_resolution/procedures/frame_candidates schemas/function_resolution/procedures/resolve_bucket schemas/function_resolution/procedures/bucket_catalog_row schemas/function_resolution/procedures/bound_bucket_id schemas/function_resolution/procedures/resolve_api schemas/function_resolution/procedures/resolve_payload_refs] 2017-08-11T08:11:51Z constructive # resolve-before-dispatch capability bundle schemas/function_resolution/procedures/validate_capabilities [schemas/function_resolution/schema schemas/function_resolution/procedures/resolve_capabilities] 2017-08-11T08:11:51Z constructive # prove declarations resolvable schemas/function_resolution/procedures/image_catalog_row [schemas/function_resolution/schema schemas/function_resolution/procedures/frame_candidates] 2017-08-11T08:11:51Z constructive # reachable image by name (nearest frame wins) +schemas/function_resolution/procedures/install_route_bindings [schemas/function_resolution/schema schemas/function_resolution/procedures/resolve metaschema-modules:schemas/metaschema_modules_public/tables/site_surface_module/table metaschema-modules:schemas/metaschema_modules_public/tables/route_module/table metaschema-modules:schemas/metaschema_modules_public/tables/resource_module/table pgpm-app-scope:schemas/app_scope/procedures/routing_tables] 2026-08-19T22:00:01Z devin # install a set of route bindings (function or service targets) onto a site at one scope for one entity +schemas/function_resolution/procedures/install_mantra [schemas/function_resolution/schema schemas/function_resolution/procedures/install_route_bindings] 2026-08-19T22:00:02Z devin # the mantra page set as function-target bindings over the general engine diff --git a/packages/function-resolution/revert/schemas/function_resolution/procedures/enqueue.sql b/packages/function-resolution/revert/schemas/function_resolution/procedures/enqueue.sql index ee5771693..d0d6dbc2d 100644 --- a/packages/function-resolution/revert/schemas/function_resolution/procedures/enqueue.sql +++ b/packages/function-resolution/revert/schemas/function_resolution/procedures/enqueue.sql @@ -2,6 +2,6 @@ BEGIN; -DROP FUNCTION function_resolution.enqueue(text, json, text, uuid, uuid, text, text, text, timestamptz, int4, int4, uuid, text, bool, text, uuid, uuid); +DROP FUNCTION function_resolution.enqueue(text, json, text, uuid, uuid, text, text, text, timestamptz, int4, int4, uuid, text, bool, text, uuid, uuid, uuid, uuid); COMMIT; diff --git a/packages/function-resolution/revert/schemas/function_resolution/procedures/install_mantra.sql b/packages/function-resolution/revert/schemas/function_resolution/procedures/install_mantra.sql new file mode 100644 index 000000000..dd51f11d3 --- /dev/null +++ b/packages/function-resolution/revert/schemas/function_resolution/procedures/install_mantra.sql @@ -0,0 +1,7 @@ +-- Revert schemas/function_resolution/procedures/install_mantra from pg + +BEGIN; + +DROP FUNCTION function_resolution.install_mantra(uuid, regclass, uuid, jsonb, uuid); + +COMMIT; diff --git a/packages/function-resolution/revert/schemas/function_resolution/procedures/install_route_bindings.sql b/packages/function-resolution/revert/schemas/function_resolution/procedures/install_route_bindings.sql new file mode 100644 index 000000000..58bfc97f6 --- /dev/null +++ b/packages/function-resolution/revert/schemas/function_resolution/procedures/install_route_bindings.sql @@ -0,0 +1,7 @@ +-- Revert schemas/function_resolution/procedures/install_route_bindings from pg + +BEGIN; + +DROP FUNCTION function_resolution.install_route_bindings(uuid, text, text, uuid, jsonb, uuid); + +COMMIT; diff --git a/packages/function-resolution/sql/pgpm-function-resolution--0.43.2.bundle.tar.gz b/packages/function-resolution/sql/pgpm-function-resolution--0.43.2.bundle.tar.gz index 1a1c1dd46..420c21ec5 100644 Binary files a/packages/function-resolution/sql/pgpm-function-resolution--0.43.2.bundle.tar.gz and b/packages/function-resolution/sql/pgpm-function-resolution--0.43.2.bundle.tar.gz differ diff --git a/packages/function-resolution/sql/pgpm-function-resolution--0.43.2.sql b/packages/function-resolution/sql/pgpm-function-resolution--0.43.2.sql index 56d079ddf..3dc9bd258 100644 --- a/packages/function-resolution/sql/pgpm-function-resolution--0.43.2.sql +++ b/packages/function-resolution/sql/pgpm-function-resolution--0.43.2.sql @@ -309,7 +309,9 @@ CREATE FUNCTION function_resolution.enqueue( should_resolve boolean DEFAULT true, resolution_scope text DEFAULT NULL, resolution_key uuid DEFAULT NULL, - db_id uuid DEFAULT jwt_private.current_database_id() + db_id uuid DEFAULT jwt_private.require_database_id(), + actor_id uuid DEFAULT jwt_public.current_user_id(), + principal_id uuid DEFAULT jwt_public.current_principal_id() ) RETURNS app_jobs.jobs AS $EOFCODE$ DECLARE v_database_id uuid; @@ -390,12 +392,14 @@ BEGIN entity_type := entity_type, function_definition_id := v_fn_id, definition_scope := v_def_scope, - db_id := v_database_id + db_id := v_database_id, + actor_id := enqueue.actor_id, + principal_id := enqueue.principal_id ); END; $EOFCODE$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER; -COMMENT ON FUNCTION function_resolution.enqueue(text, pg_catalog.json, text, uuid, uuid, text, text, text, timestamptz, int, int, uuid, text, boolean, text, uuid, uuid) IS 'Resolver-aware job enqueue: resolves (or trusts a supplied) function definition for the execution (database, scope, entity, task_identifier), stamps the (function_definition_id, definition_scope) pair and the definition''s queue routing, then delegates the insert to app_jobs.add_job. The single enqueue path for function jobs; definition-less tasks enqueue with a NULL pair. Portable: built only on app_scope + the metaschema catalog + app_jobs, no AST/deparser runtime.'; +COMMENT ON FUNCTION function_resolution.enqueue(text, pg_catalog.json, text, uuid, uuid, text, text, text, timestamptz, int, int, uuid, text, boolean, text, uuid, uuid, uuid, uuid) IS 'Resolver-aware job enqueue: resolves (or trusts a supplied) function definition for the execution (database, scope, entity, task_identifier), stamps the (function_definition_id, definition_scope) pair and the definition''s queue routing, then delegates the insert to app_jobs.add_job. The single enqueue path for function jobs; definition-less tasks enqueue with a NULL pair. Portable: built only on app_scope + the metaschema catalog + app_jobs, no AST/deparser runtime.'; CREATE FUNCTION function_resolution.frame_candidates( database_id uuid, @@ -1647,4 +1651,490 @@ BEGIN ORDER BY cand.ord LIMIT 1; END; -$EOFCODE$ LANGUAGE plpgsql STABLE SECURITY DEFINER; \ No newline at end of file +$EOFCODE$ LANGUAGE plpgsql STABLE SECURITY DEFINER; + +CREATE FUNCTION function_resolution.install_route_bindings( + database_id uuid, + sites_schema text, + sites_table text, + site_id uuid, + bindings jsonb, + entity_id uuid DEFAULT NULL +) RETURNS jsonb AS $EOFCODE$ +DECLARE + -- plane_scope, not scope: the module registrations this reads all carry a + -- scope column, and a local named scope makes every one of those predicates + -- ambiguous. + plane_scope text; + sites_key text; + routes_schema text; + routes_table text; + routes_key text; + -- The routes column carrying the serving site, from the plane's own + -- registration. NULL for a plane that has none, which makes the stamp a + -- no-op rather than an error. + routes_serving_site_key text; + stamped int := 0; + -- The same-scope resources plane a service binding must resolve within, and + -- its own recorded ownership key. NULL when this scope carries no resources + -- plane, which makes a service binding a hard error rather than an + -- unchecked insert. + resources_schema text; + resources_table text; + resources_key text; + -- The one ownership key value the whole install is keyed by (NULL at the + -- global tier), and the entity the resolver starts its frame walk at. + key_value uuid; + resolution_entity uuid; + site_found boolean; + domain_id uuid; + -- Per-binding state: the entry, its declared kind, and the id the route's + -- typed target column gets, together with the column that carries it. + entry jsonb; + entry_path text; + entry_target text; + entry_task text; + entry_service uuid; + target_column text; + target_id uuid; + service_found boolean; + inserted int; + installed jsonb := '[]'::jsonb; + skipped jsonb := '[]'::jsonb; + query text; +BEGIN + IF install_route_bindings.database_id IS NULL THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_DATABASE_REQUIRED: database_id is required' + USING ERRCODE = 'FR050'; + END IF; + + IF install_route_bindings.sites_schema IS NULL OR install_route_bindings.sites_table IS NULL THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_SITES_PLANE_REQUIRED: sites_schema and sites_table are required' + USING ERRCODE = 'FR050'; + END IF; + + IF install_route_bindings.site_id IS NULL THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_SITE_REQUIRED: site_id is required' + USING ERRCODE = 'FR050'; + END IF; + + -- The bindings document, validated before anything is written: an empty or + -- wrongly-shaped document is a broken install, not a no-op. + IF install_route_bindings.bindings IS NULL + OR jsonb_typeof(install_route_bindings.bindings) <> 'array' + OR jsonb_array_length(install_route_bindings.bindings) = 0 THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_INVALID: bindings must be a non-empty JSON array of {path, target, …}, got %', + coalesce(jsonb_typeof(install_route_bindings.bindings), 'null') + USING ERRCODE = 'FR060'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM jsonb_array_elements(install_route_bindings.bindings) AS b + WHERE jsonb_typeof(b) <> 'object' + OR coalesce(b ->> 'path', '') = '' + OR coalesce(b ->> 'target', '') = '' + ) THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_INVALID: every binding must carry a non-empty path and target' + USING ERRCODE = 'FR060'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM jsonb_array_elements(install_route_bindings.bindings) AS b + WHERE (b ->> 'target') NOT IN ('function', 'service') + ) THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_TARGET_UNKNOWN: target must be "function" or "service"' + USING ERRCODE = 'FR060'; + END IF; + + -- A binding carrying the key of a kind it did not declare is ambiguous, and + -- resolving it by precedence would let a typo repoint a path at a different + -- plane than the document reads like. + IF EXISTS ( + SELECT 1 + FROM jsonb_array_elements(install_route_bindings.bindings) AS b + WHERE (b ? 'task_identifier') AND (b ? 'service_id') + OR (b ->> 'target') = 'function' AND (b ? 'service_id') + OR (b ->> 'target') = 'service' AND (b ? 'task_identifier') + ) THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_TARGET_AMBIGUOUS: a binding must carry only the key of the target kind it declares' + USING ERRCODE = 'FR060'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM jsonb_array_elements(install_route_bindings.bindings) AS b + WHERE (b ->> 'target') = 'function' AND coalesce(b ->> 'task_identifier', '') = '' + OR (b ->> 'target') = 'service' AND coalesce(b ->> 'service_id', '') = '' + ) THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_INVALID: a function binding needs a task_identifier and a service binding needs a service_id' + USING ERRCODE = 'FR060'; + END IF; + + -- A service_id is cast to uuid before it is looked up, and a bare cast + -- failure reports "invalid input syntax for type uuid" with no hint of which + -- binding carried it — so the document is rejected by name instead. + IF EXISTS ( + SELECT 1 + FROM jsonb_array_elements(install_route_bindings.bindings) AS b + WHERE (b ->> 'target') = 'service' + AND (b ->> 'service_id') !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + ) THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_SERVICE_INVALID: a service binding''s service_id must be a uuid' + USING ERRCODE = 'FR060'; + END IF; + + -- Idempotency is keyed by (domain_id, path), so two entries claiming one + -- path would install the first and report the second as already-there — + -- a document that reads like it bound both. It is malformed instead. + IF EXISTS ( + SELECT 1 + FROM jsonb_array_elements(install_route_bindings.bindings) AS b + GROUP BY b ->> 'path' + HAVING count(*) > 1 + ) THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_PATH_DUPLICATED: a path may be bound once per document' + USING ERRCODE = 'FR060'; + END IF; + + -- 1. The plane's own registration decides the scope. This is the whole + -- point: the scope is a recorded fact about the sites plane being + -- installed onto, not a caller-supplied string and not a literal frozen + -- into a generated body. + -- + -- The registration is looked up ALONG THE CALLER'S FRAMES, the same walk + -- step 2 resolves the routes plane with, because a sites plane serving a + -- scope is not necessarily registered in the database consuming it: the + -- database-scope serving planes are shared, hosted by an outer frame's + -- database and keyed per tenant, so a tenant installing onto the plane it + -- is actually served by has no registration of its own to find. Keying is + -- unaffected — the scope comes from the registration and the key value from + -- the caller (step 3), so a tenant's rows stay stamped with the tenant. + -- Nearest frame wins, so a plane a database does register still resolves to + -- its own registration. + SELECT sm.scope, sm.entity_field + INTO plane_scope, sites_key + FROM app_scope.frames(install_route_bindings.database_id, 'database') WITH ORDINALITY AS f + JOIN metaschema_modules_public.site_surface_module AS sm + ON sm.scope = f.scope + AND sm.database_id = f.lookup_database_id + JOIN metaschema_public."table" AS t ON t.id = sm.sites_table_id + JOIN metaschema_public.schema AS s ON s.id = t.schema_id + WHERE s.schema_name = install_route_bindings.sites_schema + AND t.name = install_route_bindings.sites_table + ORDER BY f.ordinality + LIMIT 1; + + IF plane_scope IS NULL THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_SITES_PLANE_NOT_REGISTERED: no site_surface_module on any frame of database % registers %.% as its sites plane', + install_route_bindings.database_id, install_route_bindings.sites_schema, install_route_bindings.sites_table + USING ERRCODE = 'FR051'; + END IF; + + -- 2. The routes plane serving THAT scope, resolved the one way every + -- scope-aware consumer resolves it. + SELECT r.routes_schema, r.routes_table + INTO routes_schema, routes_table + FROM app_scope.routing_tables(install_route_bindings.database_id, plane_scope) AS r; + + IF routes_schema IS NULL OR routes_table IS NULL THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_ROUTES_PLANE_NOT_FOUND: scope "%" has no routes plane provisioned on any frame of database %', + plane_scope, install_route_bindings.database_id + USING ERRCODE = 'FR051'; + END IF; + + -- The routes plane's own registration carries its ownership key and, when + -- the plane has one, the column naming the site a route renders as. + SELECT rm.entity_field, rm.serving_site_field + INTO routes_key, routes_serving_site_key + FROM metaschema_modules_public.route_module AS rm + JOIN metaschema_public."table" AS t ON t.id = rm.routes_table_id + JOIN metaschema_public.schema AS s ON s.id = t.schema_id + WHERE s.schema_name = routes_schema + AND t.name = routes_table; + + -- Both planes are at the same scope, so scope_key_column gave them the same + -- ownership key. A mismatch means the registrations disagree about who owns + -- the rows, which is exactly the cross-scope write this function exists to + -- make impossible. + IF routes_key IS DISTINCT FROM sites_key THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_PLANE_KEY_MISMATCH: sites plane %.% is keyed by % but routes plane %.% is keyed by %', + install_route_bindings.sites_schema, install_route_bindings.sites_table, coalesce(sites_key, ''), + routes_schema, routes_table, coalesce(routes_key, '') + USING ERRCODE = 'FR051'; + END IF; + + -- The resources plane serving that same scope, from its own module + -- registration: target_service_id FKs this scope's source resources table, + -- so this is the plane the constraint would check a service binding against. + SELECT s.schema_name, t.name, rem.entity_field + INTO resources_schema, resources_table, resources_key + FROM metaschema_modules_public.resource_module AS rem + JOIN metaschema_public."table" AS t ON t.id = rem.resources_table_id + JOIN metaschema_public.schema AS s ON s.id = t.schema_id + WHERE rem.database_id = install_route_bindings.database_id + AND rem.scope = plane_scope; + + -- 3. One key value for the whole operation. + IF sites_key IS NULL THEN + key_value := NULL; + resolution_entity := NULL; + ELSIF sites_key = 'database_id' THEN + key_value := install_route_bindings.database_id; + resolution_entity := NULL; + ELSE + IF install_route_bindings.entity_id IS NULL THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_ENTITY_REQUIRED: scope "%" is keyed by %, so entity_id is required', + plane_scope, sites_key + USING ERRCODE = 'FR052'; + END IF; + key_value := install_route_bindings.entity_id; + resolution_entity := install_route_bindings.entity_id; + END IF; + + -- 4. The site, pinned to that key value. + -- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: the sites plane is named by the caller and proved by the registration join above + query := format( + 'SELECT EXISTS (SELECT 1 FROM %I.%I AS s WHERE s.id = $1%s)', + install_route_bindings.sites_schema, + install_route_bindings.sites_table, + CASE WHEN sites_key IS NULL + THEN ' AND $2 IS NULL' + ELSE format(' AND s.%I = $2', sites_key) + END + ); + + EXECUTE query INTO site_found USING install_route_bindings.site_id, key_value; + + IF NOT site_found THEN + RAISE EXCEPTION 'SITE_NOT_FOUND: no site % in %.% owned by %', + install_route_bindings.site_id, install_route_bindings.sites_schema, install_route_bindings.sites_table, + coalesce(key_value::text, plane_scope) + USING ERRCODE = 'FR053'; + END IF; + + -- 5. The hostname the site already serves. Bindings layer onto an existing + -- site route rather than claiming a hostname of their own: the root-route + -- guard auto-creates '/' carrying the target of a hostname's FIRST route, + -- so installing onto a bare hostname would make '/' the first binding's + -- target. A site that is not routed yet is a hard error, not a silently + -- empty install. + -- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: the routes plane is named by app_scope.routing_tables + query := format( + 'SELECT r.domain_id FROM %I.%I AS r WHERE r.target_site_id = $1%s ORDER BY r.path LIMIT 1', + routes_schema, + routes_table, + CASE WHEN routes_key IS NULL + THEN ' AND $2 IS NULL' + ELSE format(' AND r.%I = $2', routes_key) + END + ); + + EXECUTE query INTO domain_id USING install_route_bindings.site_id, key_value; + + IF domain_id IS NULL THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_SITE_NOT_ROUTED: site % serves no hostname in %.%, so there is nowhere to install the bindings', + install_route_bindings.site_id, routes_schema, routes_table + USING ERRCODE = 'FR054'; + END IF; + + -- 6. Per binding: resolve the declared target at THIS (scope, entity) and + -- install the route if it is not already there. + FOR entry IN SELECT * FROM jsonb_array_elements(install_route_bindings.bindings) + LOOP + entry_path := entry ->> 'path'; + entry_target := entry ->> 'target'; + + IF entry_target = 'function' THEN + entry_task := entry ->> 'task_identifier'; + + -- Fail-loud by the resolver's own contract: an unpublished task + -- raises FUNCTION_DEFINITION_NOT_FOUND rather than installing a + -- route that would 404 at request time. + SELECT fr.function_definition_id + INTO target_id + FROM function_resolution.resolve( + install_route_bindings.database_id, + plane_scope, + resolution_entity, + entry_task + ) AS fr; + + target_column := 'target_function_id'; + ELSE + entry_service := (entry ->> 'service_id')::uuid; + + IF resources_schema IS NULL THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_SERVICE_PLANE_NOT_FOUND: database % has no resource_module at scope "%", so there is no plane a service target could live in', + install_route_bindings.database_id, plane_scope + USING ERRCODE = 'FR055'; + END IF; + + -- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: the resources plane is named by this scope's own resource_module registration + query := format( + 'SELECT EXISTS (SELECT 1 FROM %I.%I AS r WHERE r.id = $1%s)', + resources_schema, + resources_table, + CASE WHEN resources_key IS NULL + THEN ' AND $2 IS NULL' + ELSE format(' AND r.%I = $2', resources_key) + END + ); + + EXECUTE query INTO service_found USING entry_service, key_value; + + IF NOT service_found THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_SERVICE_NOT_FOUND: no service % in %.% owned by %', + entry_service, resources_schema, resources_table, + coalesce(key_value::text, plane_scope) + USING ERRCODE = 'FR056'; + END IF; + + target_id := entry_service; + target_column := 'target_service_id'; + END IF; + + -- pgsql-lint-disable-next-line no-dynamic-sql -- write-only: insert into the routes plane named by app_scope.routing_tables; every value is a bound parameter + query := format( + 'INSERT INTO %I.%I (%s%sdomain_id, path, %I) + SELECT %s%s$1, $2, $3 + WHERE NOT EXISTS ( + SELECT 1 FROM %I.%I AS x + WHERE x.domain_id = $1 AND x.path = $2%s)', + routes_schema, + routes_table, + CASE WHEN routes_key IS NULL THEN '' ELSE format('%I, ', routes_key) END, + CASE WHEN routes_serving_site_key IS NULL THEN '' ELSE format('%I, ', routes_serving_site_key) END, + target_column, + CASE WHEN routes_key IS NULL THEN '' ELSE '$4, ' END, + CASE WHEN routes_serving_site_key IS NULL THEN '' ELSE '$5, ' END, + routes_schema, + routes_table, + CASE WHEN routes_key IS NULL + THEN ' AND $4 IS NULL' + ELSE format(' AND x.%I = $4', routes_key) + END + ); + + EXECUTE query USING domain_id, entry_path, target_id, key_value, + install_route_bindings.site_id; + GET DIAGNOSTICS inserted = ROW_COUNT; + + IF inserted > 0 THEN + installed := installed || jsonb_build_array(entry_path); + ELSE + skipped := skipped || jsonb_build_array(entry_path); + END IF; + END LOOP; + + -- Backfill the serving site onto the paths that were already there: the + -- insert above stamps what it writes, and a route the document claims but + -- did not create is still a route this site serves. IS DISTINCT FROM keeps a + -- repeat install a no-op write rather than a row version per path, and the + -- ownership pin repeats here so a SECURITY DEFINER caller cannot stamp a row + -- another owner holds even if a domain were ever shared across planes. + IF routes_serving_site_key IS NOT NULL AND jsonb_array_length(skipped) > 0 THEN + -- pgsql-lint-disable-next-line no-dynamic-sql -- write-only: update the routes plane named by app_scope.routing_tables; every value is a bound parameter + query := format( + 'UPDATE %I.%I AS r + SET %I = $1 + WHERE r.domain_id = $2 + AND $3 @> to_jsonb(r.path) + AND r.%I IS DISTINCT FROM $1%s', + routes_schema, + routes_table, + routes_serving_site_key, + routes_serving_site_key, + CASE WHEN routes_key IS NULL + THEN ' AND $4 IS NULL' + ELSE format(' AND r.%I = $4', routes_key) + END + ); + + EXECUTE query USING install_route_bindings.site_id, domain_id, skipped, key_value; + GET DIAGNOSTICS stamped = ROW_COUNT; + END IF; + + RETURN jsonb_build_object( + 'site_id', install_route_bindings.site_id, + 'scope', plane_scope, + 'entity_id', install_route_bindings.entity_id, + 'domain_id', domain_id, + 'routes_schema', routes_schema, + 'routes_table', routes_table, + 'installed', installed, + 'skipped', skipped, + 'serving_site_field', routes_serving_site_key, + 'serving_site_backfilled', stamped + ); +END; +$EOFCODE$ LANGUAGE plpgsql VOLATILE; + +COMMENT ON FUNCTION function_resolution.install_route_bindings(uuid, text, text, uuid, jsonb, uuid) IS 'Install a set of route bindings — a JSON array of {path, target, …} entries, each NAMING its target kind ("function" with a task_identifier, or "service" with a service_id) — onto one site as ordinary route rows, at ONE scope for ONE entity. The scope and ownership key are read from the named sites plane''s own site_surface_module registration, located along the caller''s frames (nearest first) so a shared serving plane hosted by an outer frame''s database resolves for the tenant consuming it — never a caller-supplied or generated literal — and that one (scope, key) then names the routes plane (app_scope.routing_tables), pins the site, route and service reads, starts function_resolution.resolve''s frame walk, and stamps every inserted row; a service target is proved to exist in the same-scope resources plane the routes plane''s registration records, which is the plane target_service_id FKs. Idempotent per (domain_id, path); raises on a malformed document, an unknown or ambiguous target kind, an unregistered plane, a scope with no routes plane, a missing entity key, an unknown site, an unrouted site, a scope with no resources plane, an unresolvable service, or an unpublished task.'; + +CREATE FUNCTION function_resolution.install_mantra( + database_id uuid, + sites_plane regclass, + site_id uuid, + bindings jsonb, + entity_id uuid DEFAULT NULL +) RETURNS jsonb AS $EOFCODE$ +DECLARE + function_bindings jsonb; + sites_schema text; + sites_table text; +BEGIN + IF install_mantra.sites_plane IS NULL THEN + RAISE EXCEPTION 'MANTRA_SITES_PLANE_REQUIRED: sites_plane is required' + USING ERRCODE = 'FR050'; + END IF; + + -- The plane's names, read off the catalog entry the reference itself proves + -- exists, and handed to the engine as the registry records them. + SELECT n.nspname, c.relname + INTO sites_schema, sites_table + FROM pg_catalog.pg_class AS c + JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace + WHERE c.oid = install_mantra.sites_plane; + + -- The Mantra document contract, checked here because it is stricter than the + -- engine's: every entry names a task, and none of them may name a target + -- kind — a preset row that has grown a service binding is a broken preset, + -- not a mixed install. + IF install_mantra.bindings IS NULL + OR jsonb_typeof(install_mantra.bindings) <> 'array' + OR jsonb_array_length(install_mantra.bindings) = 0 THEN + RAISE EXCEPTION 'MANTRA_BINDINGS_INVALID: bindings must be a non-empty JSON array of {path, task_identifier}, got %', + coalesce(jsonb_typeof(install_mantra.bindings), 'null') + USING ERRCODE = 'FR060'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM jsonb_array_elements(install_mantra.bindings) AS b + WHERE jsonb_typeof(b) <> 'object' + OR coalesce(b ->> 'path', '') = '' + OR coalesce(b ->> 'task_identifier', '') = '' + OR b ? 'target' + ) THEN + RAISE EXCEPTION 'MANTRA_BINDINGS_INVALID: every binding must carry a non-empty path and task_identifier, and no target kind' + USING ERRCODE = 'FR060'; + END IF; + + SELECT jsonb_agg(b || jsonb_build_object('target', 'function')) + INTO function_bindings + FROM jsonb_array_elements(install_mantra.bindings) AS b; + + RETURN function_resolution.install_route_bindings( + install_mantra.database_id, + sites_schema, + sites_table, + install_mantra.site_id, + function_bindings, + install_mantra.entity_id + ); +END; +$EOFCODE$ LANGUAGE plpgsql VOLATILE; + +COMMENT ON FUNCTION function_resolution.install_mantra(uuid, regclass, uuid, jsonb, uuid) IS 'Install the Mantra page set (a JSON array of {path, task_identifier}, which the generated verb reads from the content_presets catalog at kind ''route_bindings'') onto one site as ordinary function-target routes. The sites plane arrives by reference as a regclass, so a generated caller never spells a schema name in a bare string literal the platform export''s AST rename cannot follow. A thin wrapper holding the Mantra document contract — every entry names a task, none names a target kind — over function_resolution.install_route_bindings, which owns the one-scope install: scope and ownership key read from the sites plane''s own registration, the routes plane from app_scope.routing_tables, resolution at that same (scope, entity), idempotent per (domain_id, path).'; \ No newline at end of file diff --git a/packages/function-resolution/verify/schemas/function_resolution/procedures/enqueue.sql b/packages/function-resolution/verify/schemas/function_resolution/procedures/enqueue.sql index 46f383df3..27475e85d 100644 --- a/packages/function-resolution/verify/schemas/function_resolution/procedures/enqueue.sql +++ b/packages/function-resolution/verify/schemas/function_resolution/procedures/enqueue.sql @@ -2,6 +2,6 @@ BEGIN; -SELECT assert_function('function_resolution.enqueue(text, json, text, uuid, uuid, text, text, text, timestamptz, int4, int4, uuid, text, bool, text, uuid, uuid)'::regprocedure); +SELECT assert_function('function_resolution.enqueue(text, json, text, uuid, uuid, text, text, text, timestamptz, int4, int4, uuid, text, bool, text, uuid, uuid, uuid, uuid)'::regprocedure); ROLLBACK; diff --git a/packages/function-resolution/verify/schemas/function_resolution/procedures/install_mantra.sql b/packages/function-resolution/verify/schemas/function_resolution/procedures/install_mantra.sql new file mode 100644 index 000000000..5f9b8de87 --- /dev/null +++ b/packages/function-resolution/verify/schemas/function_resolution/procedures/install_mantra.sql @@ -0,0 +1,7 @@ +-- Verify schemas/function_resolution/procedures/install_mantra on pg + +BEGIN; + +SELECT assert_function('function_resolution.install_mantra(uuid, regclass, uuid, jsonb, uuid)'::regprocedure); + +ROLLBACK; diff --git a/packages/function-resolution/verify/schemas/function_resolution/procedures/install_route_bindings.sql b/packages/function-resolution/verify/schemas/function_resolution/procedures/install_route_bindings.sql new file mode 100644 index 000000000..c0841cdf3 --- /dev/null +++ b/packages/function-resolution/verify/schemas/function_resolution/procedures/install_route_bindings.sql @@ -0,0 +1,7 @@ +-- Verify schemas/function_resolution/procedures/install_route_bindings on pg + +BEGIN; + +SELECT assert_function('function_resolution.install_route_bindings(uuid, text, text, uuid, jsonb, uuid)'::regprocedure); + +ROLLBACK; diff --git a/packages/jwt-claims/__tests__/jwt.test.ts b/packages/jwt-claims/__tests__/jwt.test.ts index ee5da7149..48aef4b5f 100644 --- a/packages/jwt-claims/__tests__/jwt.test.ts +++ b/packages/jwt-claims/__tests__/jwt.test.ts @@ -5,7 +5,9 @@ let teardown: () => Promise; const jwt = { user_id: 'b9d22af1-62c7-43a5-b8c4-50630bbd4962', - database_id: '44744c94-93cf-425a-b524-ce6f1466e327' + database_id: '44744c94-93cf-425a-b524-ce6f1466e327', + entity_id: 'f12f1f0d-6f62-4f4b-93f2-72ee5d2a5b8e', + entity_type: 'org' }; beforeAll(async () => { @@ -66,10 +68,266 @@ it('current_database_id returns the claim when it is set', async () => { expect(database_id).toEqual(jwt.database_id); }); -it('current_database_id raises DATABASE_CLAIM_REQUIRED when the claim is absent', async () => { +it('require_database_id raises DATABASE_CLAIM_REQUIRED when the claim is absent', async () => { await pg.any(`BEGIN`); await expect( - pg.one(`select jwt_private.current_database_id()`) + pg.one(`select jwt_private.require_database_id()`) ).rejects.toThrow('DATABASE_CLAIM_REQUIRED'); await pg.any(`ROLLBACK`); }); + +it('require_user_id raises ACTOR_CLAIM_REQUIRED when the claim is absent', async () => { + await pg.any(`BEGIN`); + await expect( + pg.one(`select jwt_public.require_user_id()`) + ).rejects.toThrow('ACTOR_CLAIM_REQUIRED'); + await pg.any(`ROLLBACK`); +}); + +it.each([ + ['unset', null], + ['empty', ''], + ['malformed', 'not-a-uuid'] +])('current_database_id returns NULL for a %s claim', async (_label, value) => { + await pg.any(`BEGIN`); + if (value === null) { + await pg.any(`RESET "jwt.claims.database_id"`); + } else { + await pg.any( + `SELECT set_config('jwt.claims.database_id', $1, true)`, + [value] + ); + } + const { database_id } = await pg.one( + `select jwt_private.current_database_id() as database_id` + ); + await pg.any(`ROLLBACK`); + + expect(database_id).toBeNull(); +}); + +describe('entity claim readers', () => { + it.each([ + ['unset', null, null], + ['malformed', 'not-a-uuid', null], + ['valid', jwt.entity_id, jwt.entity_id] + ])('current_entity_id returns %s', async (_label, value, expected) => { + await pg.any(`BEGIN`); + if (value === null) { + await pg.any(`RESET "jwt.claims.entity_id"`); + } else { + await pg.any( + `SELECT set_config('jwt.claims.entity_id', $1, true)`, + [value] + ); + } + const { entity_id } = await pg.one( + `select jwt_private.current_entity_id() as entity_id` + ); + await pg.any(`ROLLBACK`); + + expect(entity_id).toEqual(expected); + }); + + it('current_entity_id returns NULL for transaction-local residue after COMMIT', async () => { + try { + await pg.any(`BEGIN`); + await pg.any( + `SELECT set_config('jwt.claims.entity_id', $1, true)`, + [jwt.entity_id] + ); + await pg.any(`COMMIT`); + + const { entity_id } = await pg.one( + `select jwt_private.current_entity_id() as entity_id` + ); + expect(entity_id).toBeNull(); + } finally { + await pg.any(`SELECT set_config('jwt.claims.entity_id', NULL, false)`); + } + }); + + it.each([ + ['unset', null, null], + ['valid', jwt.entity_type, jwt.entity_type] + ])('current_entity_type returns %s', async (_label, value, expected) => { + await pg.any(`BEGIN`); + if (value === null) { + await pg.any(`RESET "jwt.claims.entity_type"`); + } else { + await pg.any( + `SELECT set_config('jwt.claims.entity_type', $1, true)`, + [value] + ); + } + const { entity_type } = await pg.one( + `select jwt_private.current_entity_type() as entity_type` + ); + await pg.any(`ROLLBACK`); + + expect(entity_type).toEqual(expected); + }); + + it('current_entity_type returns NULL for transaction-local residue after COMMIT', async () => { + try { + await pg.any(`BEGIN`); + await pg.any( + `SELECT set_config('jwt.claims.entity_type', $1, true)`, + [jwt.entity_type] + ); + await pg.any(`COMMIT`); + + const { entity_type } = await pg.one( + `select jwt_private.current_entity_type() as entity_type` + ); + expect(entity_type).toBeNull(); + } finally { + await pg.any(`SELECT set_config('jwt.claims.entity_type', NULL, false)`); + } + }); + + it('require_database_id raises for a malformed claim', async () => { + await pg.any(`BEGIN`); + await pg.any( + `SELECT set_config('jwt.claims.database_id', $1, true)`, + ['not-a-uuid'] + ); + await expect( + pg.one(`select jwt_private.require_database_id()`) + ).rejects.toThrow('DATABASE_CLAIM_REQUIRED'); + await pg.any(`ROLLBACK`); + }); + + it('require_entity_id raises for an absent claim', async () => { + await pg.any(`BEGIN`); + await expect( + pg.one(`select jwt_private.require_entity_id()`) + ).rejects.toThrow('ENTITY_CLAIM_REQUIRED'); + await pg.any(`ROLLBACK`); + }); + + it('require_entity_type raises for an absent claim', async () => { + await pg.any(`BEGIN`); + await expect( + pg.one(`select jwt_private.require_entity_type()`) + ).rejects.toThrow('ENTITY_TYPE_CLAIM_REQUIRED'); + await pg.any(`ROLLBACK`); + }); +}); + +describe('attribution assertion', () => { + it('raises on incomplete attribution by default', async () => { + await pg.any(`BEGIN`); + try { + await pg.one(`select jwt_private.assert_attribution(NULL, NULL, NULL)`); + throw new Error('assert_attribution did not raise'); + } catch (err) { + expect(err).toMatchObject({ code: 'P0001' }); + } finally { + await pg.any(`ROLLBACK`); + } + }); + + it('warns and allows incomplete attribution when strict mode is disabled', async () => { + const notices: Array<{ message?: string }> = []; + const onNotice = (notice: { message?: string }) => notices.push(notice); + pg.client.on('notice', onNotice); + await pg.any(`BEGIN`); + await pg.any( + `SELECT set_config('jwt.strict_attribution', 'false', true)` + ); + try { + await pg.one(`select jwt_private.assert_attribution(NULL, NULL, NULL)`); + await pg.one( + `select jwt_private.assert_attribution(NULL, $1::uuid, NULL)`, + [jwt.entity_id] + ); + await pg.one( + `select jwt_private.assert_attribution($1::uuid, NULL, $2::text)`, + [jwt.user_id, jwt.entity_type] + ); + await pg.any(`CREATE TEMP TABLE attribution_probe(value integer)`); + await pg.any(`INSERT INTO attribution_probe VALUES (1)`); + const [{ value }] = await pg.any( + `SELECT value FROM attribution_probe` + ); + expect(value).toBe(1); + } finally { + await pg.any(`ROLLBACK`); + pg.client.off('notice', onNotice); + } + + expect(notices.map(({ message }) => message)).toEqual( + expect.arrayContaining([ + expect.stringContaining('ATTRIBUTION_REQUIRED'), + expect.stringContaining('ENTITY_TYPE_REQUIRED'), + expect.stringContaining('ENTITY_ID_REQUIRED'), + ]) + ); + }); + + it('raises when neither actor nor entity is present', async () => { + await pg.any(`BEGIN`); + await pg.any(`SELECT set_config('jwt.strict_attribution', 'true', true)`); + try { + await pg.one(`select jwt_private.assert_attribution(NULL, NULL, NULL)`); + throw new Error('assert_attribution did not raise'); + } catch (err) { + expect(err).toMatchObject({ code: 'P0001' }); + const detail = JSON.parse((err as { detail: string }).detail) as { + code: string; + context: { + arguments: string[]; + claims: string[]; + }; + }; + expect(detail.code).toBe('ATTRIBUTION_REQUIRED'); + expect(detail.context).toEqual({ + arguments: ['actor_id', 'entity_id'], + claims: ['jwt.claims.user_id', 'jwt.claims.entity_id'], + }); + } + await pg.any(`ROLLBACK`); + }); + + it('raises when an entity has no entity type', async () => { + await pg.any(`BEGIN`); + await pg.any(`SELECT set_config('jwt.strict_attribution', 'true', true)`); + await expect( + pg.one( + `select jwt_private.assert_attribution(NULL, $1::uuid, NULL)`, + [jwt.entity_id] + ) + ).rejects.toThrow('ENTITY_TYPE_REQUIRED'); + await pg.any(`ROLLBACK`); + }); + + it('raises when an entity type has no entity', async () => { + await pg.any(`BEGIN`); + await pg.any(`SELECT set_config('jwt.strict_attribution', 'true', true)`); + await expect( + pg.one( + `select jwt_private.assert_attribution($1::uuid, NULL, $2::text)`, + [jwt.user_id, jwt.entity_type] + ) + ).rejects.toThrow('ENTITY_ID_REQUIRED'); + await pg.any(`ROLLBACK`); + }); + + it('allows actor-only attribution', async () => { + await expect( + pg.one(`select jwt_private.assert_attribution($1::uuid, NULL, NULL)`, [ + jwt.user_id + ]) + ).resolves.toBeDefined(); + }); + + it('allows row-derived entity attribution with its type', async () => { + await expect( + pg.one( + `select jwt_private.assert_attribution(NULL, $1::uuid, $2::text)`, + [jwt.entity_id, jwt.entity_type] + ) + ).resolves.toBeDefined(); + }); +}); diff --git a/packages/jwt-claims/deploy/schemas/ctx/procedures/ip_address.sql b/packages/jwt-claims/deploy/schemas/ctx/procedures/ip_address.sql index 864bb146f..cc152738c 100644 --- a/packages/jwt-claims/deploy/schemas/ctx/procedures/ip_address.sql +++ b/packages/jwt-claims/deploy/schemas/ctx/procedures/ip_address.sql @@ -12,7 +12,6 @@ CREATE FUNCTION ctx.ip_address() AS $$ SELECT nullif(current_setting('jwt.claims.ip_address', true), '')::inet; $$ -LANGUAGE 'sql' STABLE; +LANGUAGE 'sql' STABLE LEAKPROOF PARALLEL SAFE; COMMIT; - diff --git a/packages/jwt-claims/deploy/schemas/ctx/procedures/origin.sql b/packages/jwt-claims/deploy/schemas/ctx/procedures/origin.sql index 5c23772f9..ece7b9189 100644 --- a/packages/jwt-claims/deploy/schemas/ctx/procedures/origin.sql +++ b/packages/jwt-claims/deploy/schemas/ctx/procedures/origin.sql @@ -12,7 +12,6 @@ CREATE FUNCTION ctx.origin() AS $$ SELECT nullif(current_setting('jwt.claims.origin', true), '')::origin; $$ -LANGUAGE 'sql' STABLE; +LANGUAGE 'sql' STABLE LEAKPROOF PARALLEL SAFE; COMMIT; - diff --git a/packages/jwt-claims/deploy/schemas/ctx/procedures/uagent.sql b/packages/jwt-claims/deploy/schemas/ctx/procedures/uagent.sql index cf4423f55..1e7df4b5d 100644 --- a/packages/jwt-claims/deploy/schemas/ctx/procedures/uagent.sql +++ b/packages/jwt-claims/deploy/schemas/ctx/procedures/uagent.sql @@ -12,7 +12,6 @@ CREATE FUNCTION ctx.uagent() AS $$ SELECT nullif(current_setting('jwt.claims.user_agent', true), ''); $$ -LANGUAGE 'sql' STABLE; +LANGUAGE 'sql' STABLE LEAKPROOF PARALLEL SAFE; COMMIT; - diff --git a/packages/jwt-claims/deploy/schemas/ctx/procedures/uid.sql b/packages/jwt-claims/deploy/schemas/ctx/procedures/uid.sql index ed2bca9a9..367672cc3 100644 --- a/packages/jwt-claims/deploy/schemas/ctx/procedures/uid.sql +++ b/packages/jwt-claims/deploy/schemas/ctx/procedures/uid.sql @@ -12,7 +12,6 @@ CREATE FUNCTION ctx.uid() AS $$ SELECT nullif(current_setting('jwt.claims.user_id', true), '')::uuid; $$ -LANGUAGE 'sql' STABLE; +LANGUAGE 'sql' STABLE LEAKPROOF PARALLEL SAFE; COMMIT; - diff --git a/packages/jwt-claims/deploy/schemas/jwt_private/procedures/assert_attribution.sql b/packages/jwt-claims/deploy/schemas/jwt_private/procedures/assert_attribution.sql new file mode 100644 index 000000000..195e148c5 --- /dev/null +++ b/packages/jwt-claims/deploy/schemas/jwt_private/procedures/assert_attribution.sql @@ -0,0 +1,77 @@ +-- Deploy schemas/jwt_private/procedures/assert_attribution to pg +-- Raises on incomplete attribution by default; warns only when strict mode is +-- explicitly disabled. Set jwt.strict_attribution = 'false' at a boundary that +-- must tolerate incomplete attribution while its writers are being fixed. + +-- requires: schemas/jwt_private/schema + +BEGIN; + +CREATE FUNCTION jwt_private.assert_attribution( + actor_id uuid, + entity_id uuid, + entity_type text +) + RETURNS void +AS $$ +DECLARE + strict_attribution boolean := + COALESCE( + NULLIF(current_setting('jwt.strict_attribution', true), ''), + 'true' + ) <> 'false'; + context jsonb; +BEGIN + IF actor_id IS NULL AND entity_id IS NULL THEN + context := jsonb_build_object( + 'arguments', jsonb_build_array('actor_id', 'entity_id'), + 'claims', jsonb_build_array('jwt.claims.user_id', 'jwt.claims.entity_id') + ); + IF strict_attribution THEN + PERFORM errors.raise_error('ATTRIBUTION_REQUIRED', context, 'internal'); + ELSE + RAISE WARNING '%', + jsonb_build_object( + 'code', 'ATTRIBUTION_REQUIRED', + 'class', 'internal', + 'context', context + ); + END IF; + ELSIF entity_id IS NOT NULL AND entity_type IS NULL THEN + context := jsonb_build_object( + 'argument', 'entity_type', + 'claim', 'jwt.claims.entity_type', + 'entity_id', entity_id + ); + IF strict_attribution THEN + PERFORM errors.raise_error('ENTITY_TYPE_REQUIRED', context, 'internal'); + ELSE + RAISE WARNING '%', + jsonb_build_object( + 'code', 'ENTITY_TYPE_REQUIRED', + 'class', 'internal', + 'context', context + ); + END IF; + ELSIF entity_type IS NOT NULL AND entity_id IS NULL THEN + context := jsonb_build_object( + 'argument', 'entity_id', + 'claim', 'jwt.claims.entity_id', + 'entity_type', entity_type + ); + IF strict_attribution THEN + PERFORM errors.raise_error('ENTITY_ID_REQUIRED', context, 'internal'); + ELSE + RAISE WARNING '%', + jsonb_build_object( + 'code', 'ENTITY_ID_REQUIRED', + 'class', 'internal', + 'context', context + ); + END IF; + END IF; +END; +$$ +LANGUAGE 'plpgsql' STABLE PARALLEL SAFE; + +COMMIT; diff --git a/packages/jwt-claims/deploy/schemas/jwt_private/procedures/current_api_id.sql b/packages/jwt-claims/deploy/schemas/jwt_private/procedures/current_api_id.sql index 5f426bd7b..6a2ffa178 100644 --- a/packages/jwt-claims/deploy/schemas/jwt_private/procedures/current_api_id.sql +++ b/packages/jwt-claims/deploy/schemas/jwt_private/procedures/current_api_id.sql @@ -16,6 +16,6 @@ AS $$ THEN current_setting('jwt.claims.api_id', TRUE)::uuid END; $$ -LANGUAGE 'sql' STABLE LEAKPROOF; +LANGUAGE 'sql' STABLE LEAKPROOF PARALLEL SAFE; COMMIT; diff --git a/packages/jwt-claims/deploy/schemas/jwt_private/procedures/current_database_id.sql b/packages/jwt-claims/deploy/schemas/jwt_private/procedures/current_database_id.sql index 92c3ff7e0..13a826546 100644 --- a/packages/jwt-claims/deploy/schemas/jwt_private/procedures/current_database_id.sql +++ b/packages/jwt-claims/deploy/schemas/jwt_private/procedures/current_database_id.sql @@ -5,41 +5,18 @@ BEGIN; --- Returns the current database UUID from the JWT claims --- Used for multi-tenant database isolation --- --- The claim is set at every session boundary (API, worker, provisioning, --- platform deploy/seed), so a missing or malformed claim is never a --- legitimate state: it means a scoped write is about to run unattributed. --- Fails loudly instead of returning NULL, so the omission surfaces at the --- statement that needed the identity rather than as a NULL that travels. --- -- pg_input_is_valid() validates without raising, so no EXCEPTION block is -- needed: an exception handler would open a subtransaction on every call --- --- LEAKPROOF like its sibling claim readers: it takes no arguments, so it can --- leak nothing about the row a policy is testing, and the planner may push the --- claim comparison down instead of treating every policy that reads a claim as --- a barrier. Its one error path depends on the session, never on a value. +-- LEAKPROOF is safe here: with no arguments, this function cannot reveal +-- anything about the row a policy is testing, so claim comparisons can be pushed down. CREATE FUNCTION jwt_private.current_database_id() RETURNS uuid AS $$ -DECLARE - database_id uuid; -BEGIN - IF pg_input_is_valid(current_setting('jwt.claims.database_id', TRUE), 'uuid') THEN - database_id = current_setting('jwt.claims.database_id', TRUE)::uuid; - END IF; - IF database_id IS NULL THEN - PERFORM errors.raise_error( - 'DATABASE_CLAIM_REQUIRED', - jsonb_build_object('claim', 'jwt.claims.database_id'), - 'internal' - ); - END IF; - RETURN database_id; -END; + SELECT CASE + WHEN pg_input_is_valid(current_setting('jwt.claims.database_id', TRUE), 'uuid') + THEN current_setting('jwt.claims.database_id', TRUE)::uuid + END; $$ -LANGUAGE 'plpgsql' STABLE LEAKPROOF; +LANGUAGE 'sql' STABLE LEAKPROOF PARALLEL SAFE; COMMIT; diff --git a/packages/jwt-claims/deploy/schemas/jwt_private/procedures/current_entity_id.sql b/packages/jwt-claims/deploy/schemas/jwt_private/procedures/current_entity_id.sql new file mode 100644 index 000000000..1d05f0d95 --- /dev/null +++ b/packages/jwt-claims/deploy/schemas/jwt_private/procedures/current_entity_id.sql @@ -0,0 +1,19 @@ +-- Deploy schemas/jwt_private/procedures/current_entity_id to pg +-- Retrieves the current entity ID from JWT claims (private/internal use) + +-- requires: schemas/jwt_private/schema + +BEGIN; + +-- Returns NULL if the claim is not set, empty, or not a valid UUID +CREATE FUNCTION jwt_private.current_entity_id() + RETURNS uuid +AS $$ + SELECT CASE + WHEN pg_input_is_valid(current_setting('jwt.claims.entity_id', TRUE), 'uuid') + THEN current_setting('jwt.claims.entity_id', TRUE)::uuid + END; +$$ +LANGUAGE 'sql' STABLE LEAKPROOF PARALLEL SAFE; + +COMMIT; diff --git a/packages/jwt-claims/deploy/schemas/jwt_private/procedures/current_entity_type.sql b/packages/jwt-claims/deploy/schemas/jwt_private/procedures/current_entity_type.sql new file mode 100644 index 000000000..f5320f075 --- /dev/null +++ b/packages/jwt-claims/deploy/schemas/jwt_private/procedures/current_entity_type.sql @@ -0,0 +1,15 @@ +-- Deploy schemas/jwt_private/procedures/current_entity_type to pg +-- Retrieves the current entity type from JWT claims (private/internal use) + +-- requires: schemas/jwt_private/schema + +BEGIN; + +CREATE FUNCTION jwt_private.current_entity_type() + RETURNS text +AS $$ + SELECT NULLIF(current_setting('jwt.claims.entity_type', TRUE), ''); +$$ +LANGUAGE 'sql' STABLE LEAKPROOF PARALLEL SAFE; + +COMMIT; diff --git a/packages/jwt-claims/deploy/schemas/jwt_private/procedures/current_graph_execution_id.sql b/packages/jwt-claims/deploy/schemas/jwt_private/procedures/current_graph_execution_id.sql index 87f2c59d9..4cf912072 100644 --- a/packages/jwt-claims/deploy/schemas/jwt_private/procedures/current_graph_execution_id.sql +++ b/packages/jwt-claims/deploy/schemas/jwt_private/procedures/current_graph_execution_id.sql @@ -16,6 +16,6 @@ AS $$ THEN current_setting('jwt.claims.graph_execution_id', TRUE)::uuid END; $$ -LANGUAGE 'sql' STABLE LEAKPROOF; +LANGUAGE 'sql' STABLE LEAKPROOF PARALLEL SAFE; COMMIT; diff --git a/packages/jwt-claims/deploy/schemas/jwt_private/procedures/current_session_id.sql b/packages/jwt-claims/deploy/schemas/jwt_private/procedures/current_session_id.sql index ccf41ae0a..f83e5bf0e 100644 --- a/packages/jwt-claims/deploy/schemas/jwt_private/procedures/current_session_id.sql +++ b/packages/jwt-claims/deploy/schemas/jwt_private/procedures/current_session_id.sql @@ -13,6 +13,6 @@ CREATE FUNCTION jwt_private.current_session_id() AS $$ SELECT nullif(current_setting('jwt.claims.session_id', true), '')::uuid; $$ -LANGUAGE 'sql' STABLE; +LANGUAGE 'sql' STABLE LEAKPROOF PARALLEL SAFE; COMMIT; diff --git a/packages/jwt-claims/deploy/schemas/jwt_private/procedures/current_token_id.sql b/packages/jwt-claims/deploy/schemas/jwt_private/procedures/current_token_id.sql index 67d0cba98..f4b828b7c 100644 --- a/packages/jwt-claims/deploy/schemas/jwt_private/procedures/current_token_id.sql +++ b/packages/jwt-claims/deploy/schemas/jwt_private/procedures/current_token_id.sql @@ -12,7 +12,6 @@ CREATE FUNCTION jwt_private.current_token_id() AS $$ SELECT nullif(current_setting('jwt.claims.token_id', true), '')::uuid; $$ -LANGUAGE 'sql' STABLE; +LANGUAGE 'sql' STABLE LEAKPROOF PARALLEL SAFE; COMMIT; - diff --git a/packages/jwt-claims/deploy/schemas/jwt_private/procedures/require_database_id.sql b/packages/jwt-claims/deploy/schemas/jwt_private/procedures/require_database_id.sql new file mode 100644 index 000000000..ec886a0e2 --- /dev/null +++ b/packages/jwt-claims/deploy/schemas/jwt_private/procedures/require_database_id.sql @@ -0,0 +1,31 @@ +-- Deploy schemas/jwt_private/procedures/require_database_id to pg +-- Retrieves the required database ID from JWT claims (private/internal use) + +-- requires: schemas/jwt_private/schema + +BEGIN; + +-- This strict reader is deliberately not LEAKPROOF so PostgreSQL cannot push +-- its raising claim check into an RLS security barrier. +CREATE FUNCTION jwt_private.require_database_id() + RETURNS uuid +AS $$ +DECLARE + database_id uuid; +BEGIN + IF pg_input_is_valid(current_setting('jwt.claims.database_id', TRUE), 'uuid') THEN + database_id = current_setting('jwt.claims.database_id', TRUE)::uuid; + END IF; + IF database_id IS NULL THEN + PERFORM errors.raise_error( + 'DATABASE_CLAIM_REQUIRED', + jsonb_build_object('claim', 'jwt.claims.database_id'), + 'internal' + ); + END IF; + RETURN database_id; +END; +$$ +LANGUAGE 'plpgsql' STABLE PARALLEL SAFE; + +COMMIT; diff --git a/packages/jwt-claims/deploy/schemas/jwt_private/procedures/require_entity_id.sql b/packages/jwt-claims/deploy/schemas/jwt_private/procedures/require_entity_id.sql new file mode 100644 index 000000000..56cb850c6 --- /dev/null +++ b/packages/jwt-claims/deploy/schemas/jwt_private/procedures/require_entity_id.sql @@ -0,0 +1,31 @@ +-- Deploy schemas/jwt_private/procedures/require_entity_id to pg +-- Retrieves the required entity ID from JWT claims (private/internal use) + +-- requires: schemas/jwt_private/schema + +BEGIN; + +-- This strict reader is deliberately not LEAKPROOF so PostgreSQL cannot push +-- its raising claim check into an RLS security barrier. +CREATE FUNCTION jwt_private.require_entity_id() + RETURNS uuid +AS $$ +DECLARE + entity_id uuid; +BEGIN + IF pg_input_is_valid(current_setting('jwt.claims.entity_id', TRUE), 'uuid') THEN + entity_id = current_setting('jwt.claims.entity_id', TRUE)::uuid; + END IF; + IF entity_id IS NULL THEN + PERFORM errors.raise_error( + 'ENTITY_CLAIM_REQUIRED', + jsonb_build_object('claim', 'jwt.claims.entity_id'), + 'internal' + ); + END IF; + RETURN entity_id; +END; +$$ +LANGUAGE 'plpgsql' STABLE PARALLEL SAFE; + +COMMIT; diff --git a/packages/jwt-claims/deploy/schemas/jwt_private/procedures/require_entity_type.sql b/packages/jwt-claims/deploy/schemas/jwt_private/procedures/require_entity_type.sql new file mode 100644 index 000000000..513de2879 --- /dev/null +++ b/packages/jwt-claims/deploy/schemas/jwt_private/procedures/require_entity_type.sql @@ -0,0 +1,29 @@ +-- Deploy schemas/jwt_private/procedures/require_entity_type to pg +-- Retrieves the required entity type from JWT claims (private/internal use) + +-- requires: schemas/jwt_private/schema + +BEGIN; + +-- This strict reader is deliberately not LEAKPROOF so PostgreSQL cannot push +-- its raising claim check into an RLS security barrier. +CREATE FUNCTION jwt_private.require_entity_type() + RETURNS text +AS $$ +DECLARE + entity_type text; +BEGIN + entity_type = NULLIF(current_setting('jwt.claims.entity_type', TRUE), ''); + IF entity_type IS NULL THEN + PERFORM errors.raise_error( + 'ENTITY_TYPE_CLAIM_REQUIRED', + jsonb_build_object('claim', 'jwt.claims.entity_type'), + 'internal' + ); + END IF; + RETURN entity_type; +END; +$$ +LANGUAGE 'plpgsql' STABLE PARALLEL SAFE; + +COMMIT; diff --git a/packages/jwt-claims/deploy/schemas/jwt_public/procedures/current_ip_address.sql b/packages/jwt-claims/deploy/schemas/jwt_public/procedures/current_ip_address.sql index b5f93f106..54a710dc3 100644 --- a/packages/jwt-claims/deploy/schemas/jwt_public/procedures/current_ip_address.sql +++ b/packages/jwt-claims/deploy/schemas/jwt_public/procedures/current_ip_address.sql @@ -17,6 +17,6 @@ AS $$ THEN trim(current_setting('jwt.claims.ip_address', TRUE))::inet END; $$ -LANGUAGE 'sql' STABLE; +LANGUAGE 'sql' STABLE LEAKPROOF PARALLEL SAFE; COMMIT; diff --git a/packages/jwt-claims/deploy/schemas/jwt_public/procedures/current_origin.sql b/packages/jwt-claims/deploy/schemas/jwt_public/procedures/current_origin.sql index 99f33ccaa..431b80216 100644 --- a/packages/jwt-claims/deploy/schemas/jwt_public/procedures/current_origin.sql +++ b/packages/jwt-claims/deploy/schemas/jwt_public/procedures/current_origin.sql @@ -12,7 +12,6 @@ CREATE FUNCTION jwt_public.current_origin() AS $$ SELECT nullif(current_setting('jwt.claims.origin', true), '')::origin; $$ -LANGUAGE 'sql' STABLE; +LANGUAGE 'sql' STABLE LEAKPROOF PARALLEL SAFE; COMMIT; - diff --git a/packages/jwt-claims/deploy/schemas/jwt_public/procedures/current_principal_id.sql b/packages/jwt-claims/deploy/schemas/jwt_public/procedures/current_principal_id.sql index ffac56624..a5f2ffd79 100644 --- a/packages/jwt-claims/deploy/schemas/jwt_public/procedures/current_principal_id.sql +++ b/packages/jwt-claims/deploy/schemas/jwt_public/procedures/current_principal_id.sql @@ -17,6 +17,6 @@ AS $$ THEN current_setting('jwt.claims.principal_id', TRUE)::uuid END; $$ -LANGUAGE 'sql' STABLE LEAKPROOF; +LANGUAGE 'sql' STABLE LEAKPROOF PARALLEL SAFE; COMMIT; diff --git a/packages/jwt-claims/deploy/schemas/jwt_public/procedures/current_role_type.sql b/packages/jwt-claims/deploy/schemas/jwt_public/procedures/current_role_type.sql index e846ec60d..a00057369 100644 --- a/packages/jwt-claims/deploy/schemas/jwt_public/procedures/current_role_type.sql +++ b/packages/jwt-claims/deploy/schemas/jwt_public/procedures/current_role_type.sql @@ -13,6 +13,6 @@ CREATE FUNCTION jwt_public.current_role_type() AS $$ SELECT coalesce(nullif(current_setting('jwt.claims.role_type', TRUE), ''), 'user'); $$ -LANGUAGE 'sql' STABLE LEAKPROOF; +LANGUAGE 'sql' STABLE LEAKPROOF PARALLEL SAFE; COMMIT; diff --git a/packages/jwt-claims/deploy/schemas/jwt_public/procedures/current_user_agent.sql b/packages/jwt-claims/deploy/schemas/jwt_public/procedures/current_user_agent.sql index 514903621..5b50b7629 100644 --- a/packages/jwt-claims/deploy/schemas/jwt_public/procedures/current_user_agent.sql +++ b/packages/jwt-claims/deploy/schemas/jwt_public/procedures/current_user_agent.sql @@ -13,6 +13,6 @@ CREATE FUNCTION jwt_public.current_user_agent() AS $$ SELECT current_setting('jwt.claims.user_agent', TRUE); $$ -LANGUAGE 'sql' STABLE; +LANGUAGE 'sql' STABLE LEAKPROOF PARALLEL SAFE; COMMIT; diff --git a/packages/jwt-claims/deploy/schemas/jwt_public/procedures/current_user_id.sql b/packages/jwt-claims/deploy/schemas/jwt_public/procedures/current_user_id.sql index 56d166913..e5cfbb646 100644 --- a/packages/jwt-claims/deploy/schemas/jwt_public/procedures/current_user_id.sql +++ b/packages/jwt-claims/deploy/schemas/jwt_public/procedures/current_user_id.sql @@ -16,6 +16,6 @@ AS $$ THEN current_setting('jwt.claims.user_id', TRUE)::uuid END; $$ -LANGUAGE 'sql' STABLE LEAKPROOF; +LANGUAGE 'sql' STABLE LEAKPROOF PARALLEL SAFE; COMMIT; diff --git a/packages/jwt-claims/deploy/schemas/jwt_public/procedures/require_user_id.sql b/packages/jwt-claims/deploy/schemas/jwt_public/procedures/require_user_id.sql new file mode 100644 index 000000000..6470f0541 --- /dev/null +++ b/packages/jwt-claims/deploy/schemas/jwt_public/procedures/require_user_id.sql @@ -0,0 +1,31 @@ +-- Deploy schemas/jwt_public/procedures/require_user_id to pg +-- Retrieves the required user ID from JWT claims + +-- requires: schemas/jwt_public/schema + +BEGIN; + +-- This strict reader is deliberately not LEAKPROOF so PostgreSQL cannot push +-- its raising claim check into an RLS security barrier. +CREATE FUNCTION jwt_public.require_user_id() + RETURNS uuid +AS $$ +DECLARE + user_id uuid; +BEGIN + IF pg_input_is_valid(current_setting('jwt.claims.user_id', TRUE), 'uuid') THEN + user_id = current_setting('jwt.claims.user_id', TRUE)::uuid; + END IF; + IF user_id IS NULL THEN + PERFORM errors.raise_error( + 'ACTOR_CLAIM_REQUIRED', + jsonb_build_object('claim', 'jwt.claims.user_id'), + 'internal' + ); + END IF; + RETURN user_id; +END; +$$ +LANGUAGE 'plpgsql' STABLE PARALLEL SAFE; + +COMMIT; diff --git a/packages/jwt-claims/pgpm.plan b/packages/jwt-claims/pgpm.plan index bec8a1749..4852bb805 100644 --- a/packages/jwt-claims/pgpm.plan +++ b/packages/jwt-claims/pgpm.plan @@ -17,7 +17,14 @@ schemas/jwt_public/procedures/current_principal_id [schemas/jwt_public/schema] 2 schemas/jwt_public/procedures/current_role_type [schemas/jwt_public/schema] 2026-06-28T08:00:00Z Dan Lynch # add schemas/jwt_public/procedures/current_role_type schemas/jwt_private/schema 2020-12-17T06:47:34Z Dan Lynch # add schemas/jwt_private/schema schemas/jwt_private/procedures/current_database_id [schemas/jwt_private/schema] 2020-12-17T23:22:28Z Dan Lynch # add schemas/jwt_private/procedures/current_database_id +schemas/jwt_private/procedures/require_database_id [schemas/jwt_private/schema] 2026-08-23T03:39:19Z Dan Lynch # add schemas/jwt_private/procedures/require_database_id schemas/jwt_private/procedures/current_token_id [schemas/jwt_private/schema] 2017-08-11T08:11:51Z skitch # add schemas/jwt_private/procedures/current_token_id schemas/jwt_private/procedures/current_session_id [schemas/jwt_private/schema] 2026-01-28T05:44:00Z Dan Lynch # add schemas/jwt_private/procedures/current_session_id schemas/jwt_private/procedures/current_api_id [schemas/jwt_private/schema] 2026-07-12T13:40:27Z Dan Lynch # add schemas/jwt_private/procedures/current_api_id schemas/jwt_private/procedures/current_graph_execution_id [schemas/jwt_private/schema] 2026-07-16T09:17:00Z Dan Lynch # add schemas/jwt_private/procedures/current_graph_execution_id +schemas/jwt_private/procedures/current_entity_id [schemas/jwt_private/schema] 2026-07-17T00:00:00Z Dan Lynch # add schemas/jwt_private/procedures/current_entity_id +schemas/jwt_private/procedures/current_entity_type [schemas/jwt_private/schema] 2026-07-17T00:00:00Z Dan Lynch # add schemas/jwt_private/procedures/current_entity_type +schemas/jwt_public/procedures/require_user_id [schemas/jwt_public/schema] 2026-08-23T11:44:08Z devin # add strict user claim reader +schemas/jwt_private/procedures/require_entity_id [schemas/jwt_private/schema] 2026-08-23T11:44:08Z devin # add strict entity claim reader +schemas/jwt_private/procedures/require_entity_type [schemas/jwt_private/schema] 2026-08-23T11:44:08Z devin # add strict entity type claim reader +schemas/jwt_private/procedures/assert_attribution [schemas/jwt_private/schema] 2026-08-23T11:44:08Z devin # enforce actor or entity attribution diff --git a/packages/jwt-claims/revert/schemas/jwt_private/procedures/assert_attribution.sql b/packages/jwt-claims/revert/schemas/jwt_private/procedures/assert_attribution.sql new file mode 100644 index 000000000..87ce0847d --- /dev/null +++ b/packages/jwt-claims/revert/schemas/jwt_private/procedures/assert_attribution.sql @@ -0,0 +1,7 @@ +-- Revert schemas/jwt_private/procedures/assert_attribution from pg + +BEGIN; + +DROP FUNCTION jwt_private.assert_attribution(uuid, uuid, text); + +COMMIT; diff --git a/packages/jwt-claims/revert/schemas/jwt_private/procedures/current_entity_id.sql b/packages/jwt-claims/revert/schemas/jwt_private/procedures/current_entity_id.sql new file mode 100644 index 000000000..9fe9a9a6a --- /dev/null +++ b/packages/jwt-claims/revert/schemas/jwt_private/procedures/current_entity_id.sql @@ -0,0 +1,7 @@ +-- Revert schemas/jwt_private/procedures/current_entity_id from pg + +BEGIN; + +DROP FUNCTION jwt_private.current_entity_id(); + +COMMIT; diff --git a/packages/jwt-claims/revert/schemas/jwt_private/procedures/current_entity_type.sql b/packages/jwt-claims/revert/schemas/jwt_private/procedures/current_entity_type.sql new file mode 100644 index 000000000..182e5a161 --- /dev/null +++ b/packages/jwt-claims/revert/schemas/jwt_private/procedures/current_entity_type.sql @@ -0,0 +1,7 @@ +-- Revert schemas/jwt_private/procedures/current_entity_type from pg + +BEGIN; + +DROP FUNCTION jwt_private.current_entity_type(); + +COMMIT; diff --git a/packages/jwt-claims/revert/schemas/jwt_private/procedures/require_database_id.sql b/packages/jwt-claims/revert/schemas/jwt_private/procedures/require_database_id.sql new file mode 100644 index 000000000..8570994f2 --- /dev/null +++ b/packages/jwt-claims/revert/schemas/jwt_private/procedures/require_database_id.sql @@ -0,0 +1,7 @@ +-- Revert schemas/jwt_private/procedures/require_database_id from pg + +BEGIN; + +DROP FUNCTION jwt_private.require_database_id(); + +COMMIT; diff --git a/packages/jwt-claims/revert/schemas/jwt_private/procedures/require_entity_id.sql b/packages/jwt-claims/revert/schemas/jwt_private/procedures/require_entity_id.sql new file mode 100644 index 000000000..eb7ade47f --- /dev/null +++ b/packages/jwt-claims/revert/schemas/jwt_private/procedures/require_entity_id.sql @@ -0,0 +1,7 @@ +-- Revert schemas/jwt_private/procedures/require_entity_id from pg + +BEGIN; + +DROP FUNCTION jwt_private.require_entity_id(); + +COMMIT; diff --git a/packages/jwt-claims/revert/schemas/jwt_private/procedures/require_entity_type.sql b/packages/jwt-claims/revert/schemas/jwt_private/procedures/require_entity_type.sql new file mode 100644 index 000000000..0aa7b4a7d --- /dev/null +++ b/packages/jwt-claims/revert/schemas/jwt_private/procedures/require_entity_type.sql @@ -0,0 +1,7 @@ +-- Revert schemas/jwt_private/procedures/require_entity_type from pg + +BEGIN; + +DROP FUNCTION jwt_private.require_entity_type(); + +COMMIT; diff --git a/packages/jwt-claims/revert/schemas/jwt_public/procedures/require_user_id.sql b/packages/jwt-claims/revert/schemas/jwt_public/procedures/require_user_id.sql new file mode 100644 index 000000000..886988f9c --- /dev/null +++ b/packages/jwt-claims/revert/schemas/jwt_public/procedures/require_user_id.sql @@ -0,0 +1,7 @@ +-- Revert schemas/jwt_public/procedures/require_user_id from pg + +BEGIN; + +DROP FUNCTION jwt_public.require_user_id(); + +COMMIT; diff --git a/packages/jwt-claims/sql/pgpm-jwt-claims--0.43.1.bundle.tar.gz b/packages/jwt-claims/sql/pgpm-jwt-claims--0.43.1.bundle.tar.gz index 91f73aa12..50d0045b0 100644 Binary files a/packages/jwt-claims/sql/pgpm-jwt-claims--0.43.1.bundle.tar.gz and b/packages/jwt-claims/sql/pgpm-jwt-claims--0.43.1.bundle.tar.gz differ diff --git a/packages/jwt-claims/sql/pgpm-jwt-claims--0.43.1.sql b/packages/jwt-claims/sql/pgpm-jwt-claims--0.43.1.sql index 258a44bb7..ca5a41a59 100644 --- a/packages/jwt-claims/sql/pgpm-jwt-claims--0.43.1.sql +++ b/packages/jwt-claims/sql/pgpm-jwt-claims--0.43.1.sql @@ -8,19 +8,19 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA ctx CREATE FUNCTION ctx.ip_address() RETURNS inet AS $EOFCODE$ SELECT nullif(current_setting('jwt.claims.ip_address', true), '')::inet; -$EOFCODE$ LANGUAGE sql STABLE; +$EOFCODE$ LANGUAGE sql STABLE LEAKPROOF PARALLEL safe; CREATE FUNCTION ctx.origin() RETURNS origin AS $EOFCODE$ SELECT nullif(current_setting('jwt.claims.origin', true), '')::origin; -$EOFCODE$ LANGUAGE sql STABLE; +$EOFCODE$ LANGUAGE sql STABLE LEAKPROOF PARALLEL safe; CREATE FUNCTION ctx.uagent() RETURNS text AS $EOFCODE$ SELECT nullif(current_setting('jwt.claims.user_agent', true), ''); -$EOFCODE$ LANGUAGE sql STABLE; +$EOFCODE$ LANGUAGE sql STABLE LEAKPROOF PARALLEL safe; CREATE FUNCTION ctx.uid() RETURNS uuid AS $EOFCODE$ SELECT nullif(current_setting('jwt.claims.user_id', true), '')::uuid; -$EOFCODE$ LANGUAGE sql STABLE; +$EOFCODE$ LANGUAGE sql STABLE LEAKPROOF PARALLEL safe; DO $EOFCODE$ DECLARE @@ -52,33 +52,33 @@ CREATE FUNCTION jwt_public.current_user_id() RETURNS uuid AS $EOFCODE$ WHEN pg_input_is_valid(current_setting('jwt.claims.user_id', TRUE), 'uuid') THEN current_setting('jwt.claims.user_id', TRUE)::uuid END; -$EOFCODE$ LANGUAGE sql STABLE LEAKPROOF; +$EOFCODE$ LANGUAGE sql STABLE LEAKPROOF PARALLEL safe; CREATE FUNCTION jwt_public.current_ip_address() RETURNS inet AS $EOFCODE$ SELECT CASE WHEN pg_input_is_valid(trim(current_setting('jwt.claims.ip_address', TRUE)), 'inet') THEN trim(current_setting('jwt.claims.ip_address', TRUE))::inet END; -$EOFCODE$ LANGUAGE sql STABLE; +$EOFCODE$ LANGUAGE sql STABLE LEAKPROOF PARALLEL safe; CREATE FUNCTION jwt_public.current_user_agent() RETURNS text AS $EOFCODE$ SELECT current_setting('jwt.claims.user_agent', TRUE); -$EOFCODE$ LANGUAGE sql STABLE; +$EOFCODE$ LANGUAGE sql STABLE LEAKPROOF PARALLEL safe; CREATE FUNCTION jwt_public.current_origin() RETURNS origin AS $EOFCODE$ SELECT nullif(current_setting('jwt.claims.origin', true), '')::origin; -$EOFCODE$ LANGUAGE sql STABLE; +$EOFCODE$ LANGUAGE sql STABLE LEAKPROOF PARALLEL safe; CREATE FUNCTION jwt_public.current_principal_id() RETURNS uuid AS $EOFCODE$ SELECT CASE WHEN pg_input_is_valid(current_setting('jwt.claims.principal_id', TRUE), 'uuid') THEN current_setting('jwt.claims.principal_id', TRUE)::uuid END; -$EOFCODE$ LANGUAGE sql STABLE LEAKPROOF; +$EOFCODE$ LANGUAGE sql STABLE LEAKPROOF PARALLEL safe; CREATE FUNCTION jwt_public.current_role_type() RETURNS text AS $EOFCODE$ SELECT coalesce(nullif(current_setting('jwt.claims.role_type', TRUE), ''), 'user'); -$EOFCODE$ LANGUAGE sql STABLE LEAKPROOF; +$EOFCODE$ LANGUAGE sql STABLE LEAKPROOF PARALLEL safe; CREATE SCHEMA jwt_private; @@ -88,6 +88,13 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA jwt_private GRANT EXECUTE ON FUNCTIONS TO authenticated; CREATE FUNCTION jwt_private.current_database_id() RETURNS uuid AS $EOFCODE$ + SELECT CASE + WHEN pg_input_is_valid(current_setting('jwt.claims.database_id', TRUE), 'uuid') + THEN current_setting('jwt.claims.database_id', TRUE)::uuid + END; +$EOFCODE$ LANGUAGE sql STABLE LEAKPROOF PARALLEL safe; + +CREATE FUNCTION jwt_private.require_database_id() RETURNS uuid AS $EOFCODE$ DECLARE database_id uuid; BEGIN @@ -103,26 +110,153 @@ BEGIN END IF; RETURN database_id; END; -$EOFCODE$ LANGUAGE plpgsql STABLE LEAKPROOF; +$EOFCODE$ LANGUAGE plpgsql STABLE PARALLEL safe; CREATE FUNCTION jwt_private.current_token_id() RETURNS uuid AS $EOFCODE$ SELECT nullif(current_setting('jwt.claims.token_id', true), '')::uuid; -$EOFCODE$ LANGUAGE sql STABLE; +$EOFCODE$ LANGUAGE sql STABLE LEAKPROOF PARALLEL safe; CREATE FUNCTION jwt_private.current_session_id() RETURNS uuid AS $EOFCODE$ SELECT nullif(current_setting('jwt.claims.session_id', true), '')::uuid; -$EOFCODE$ LANGUAGE sql STABLE; +$EOFCODE$ LANGUAGE sql STABLE LEAKPROOF PARALLEL safe; CREATE FUNCTION jwt_private.current_api_id() RETURNS uuid AS $EOFCODE$ SELECT CASE WHEN pg_input_is_valid(current_setting('jwt.claims.api_id', TRUE), 'uuid') THEN current_setting('jwt.claims.api_id', TRUE)::uuid END; -$EOFCODE$ LANGUAGE sql STABLE LEAKPROOF; +$EOFCODE$ LANGUAGE sql STABLE LEAKPROOF PARALLEL safe; CREATE FUNCTION jwt_private.current_graph_execution_id() RETURNS uuid AS $EOFCODE$ SELECT CASE WHEN pg_input_is_valid(current_setting('jwt.claims.graph_execution_id', TRUE), 'uuid') THEN current_setting('jwt.claims.graph_execution_id', TRUE)::uuid END; -$EOFCODE$ LANGUAGE sql STABLE LEAKPROOF; \ No newline at end of file +$EOFCODE$ LANGUAGE sql STABLE LEAKPROOF PARALLEL safe; + +CREATE FUNCTION jwt_private.current_entity_id() RETURNS uuid AS $EOFCODE$ + SELECT CASE + WHEN pg_input_is_valid(current_setting('jwt.claims.entity_id', TRUE), 'uuid') + THEN current_setting('jwt.claims.entity_id', TRUE)::uuid + END; +$EOFCODE$ LANGUAGE sql STABLE LEAKPROOF PARALLEL safe; + +CREATE FUNCTION jwt_private.current_entity_type() RETURNS text AS $EOFCODE$ + SELECT NULLIF(current_setting('jwt.claims.entity_type', TRUE), ''); +$EOFCODE$ LANGUAGE sql STABLE LEAKPROOF PARALLEL safe; + +CREATE FUNCTION jwt_public.require_user_id() RETURNS uuid AS $EOFCODE$ +DECLARE + user_id uuid; +BEGIN + IF pg_input_is_valid(current_setting('jwt.claims.user_id', TRUE), 'uuid') THEN + user_id = current_setting('jwt.claims.user_id', TRUE)::uuid; + END IF; + IF user_id IS NULL THEN + PERFORM errors.raise_error( + 'ACTOR_CLAIM_REQUIRED', + jsonb_build_object('claim', 'jwt.claims.user_id'), + 'internal' + ); + END IF; + RETURN user_id; +END; +$EOFCODE$ LANGUAGE plpgsql STABLE PARALLEL safe; + +CREATE FUNCTION jwt_private.require_entity_id() RETURNS uuid AS $EOFCODE$ +DECLARE + entity_id uuid; +BEGIN + IF pg_input_is_valid(current_setting('jwt.claims.entity_id', TRUE), 'uuid') THEN + entity_id = current_setting('jwt.claims.entity_id', TRUE)::uuid; + END IF; + IF entity_id IS NULL THEN + PERFORM errors.raise_error( + 'ENTITY_CLAIM_REQUIRED', + jsonb_build_object('claim', 'jwt.claims.entity_id'), + 'internal' + ); + END IF; + RETURN entity_id; +END; +$EOFCODE$ LANGUAGE plpgsql STABLE PARALLEL safe; + +CREATE FUNCTION jwt_private.require_entity_type() RETURNS text AS $EOFCODE$ +DECLARE + entity_type text; +BEGIN + entity_type = NULLIF(current_setting('jwt.claims.entity_type', TRUE), ''); + IF entity_type IS NULL THEN + PERFORM errors.raise_error( + 'ENTITY_TYPE_CLAIM_REQUIRED', + jsonb_build_object('claim', 'jwt.claims.entity_type'), + 'internal' + ); + END IF; + RETURN entity_type; +END; +$EOFCODE$ LANGUAGE plpgsql STABLE PARALLEL safe; + +CREATE FUNCTION jwt_private.assert_attribution( + actor_id uuid, + entity_id uuid, + entity_type text +) RETURNS void AS $EOFCODE$ +DECLARE + strict_attribution boolean := + COALESCE( + NULLIF(current_setting('jwt.strict_attribution', true), ''), + 'true' + ) <> 'false'; + context jsonb; +BEGIN + IF actor_id IS NULL AND entity_id IS NULL THEN + context := jsonb_build_object( + 'arguments', jsonb_build_array('actor_id', 'entity_id'), + 'claims', jsonb_build_array('jwt.claims.user_id', 'jwt.claims.entity_id') + ); + IF strict_attribution THEN + PERFORM errors.raise_error('ATTRIBUTION_REQUIRED', context, 'internal'); + ELSE + RAISE WARNING '%', + jsonb_build_object( + 'code', 'ATTRIBUTION_REQUIRED', + 'class', 'internal', + 'context', context + ); + END IF; + ELSIF entity_id IS NOT NULL AND entity_type IS NULL THEN + context := jsonb_build_object( + 'argument', 'entity_type', + 'claim', 'jwt.claims.entity_type', + 'entity_id', entity_id + ); + IF strict_attribution THEN + PERFORM errors.raise_error('ENTITY_TYPE_REQUIRED', context, 'internal'); + ELSE + RAISE WARNING '%', + jsonb_build_object( + 'code', 'ENTITY_TYPE_REQUIRED', + 'class', 'internal', + 'context', context + ); + END IF; + ELSIF entity_type IS NOT NULL AND entity_id IS NULL THEN + context := jsonb_build_object( + 'argument', 'entity_id', + 'claim', 'jwt.claims.entity_id', + 'entity_type', entity_type + ); + IF strict_attribution THEN + PERFORM errors.raise_error('ENTITY_ID_REQUIRED', context, 'internal'); + ELSE + RAISE WARNING '%', + jsonb_build_object( + 'code', 'ENTITY_ID_REQUIRED', + 'class', 'internal', + 'context', context + ); + END IF; + END IF; +END; +$EOFCODE$ LANGUAGE plpgsql STABLE PARALLEL safe; \ No newline at end of file diff --git a/packages/jwt-claims/verify/schemas/jwt_private/procedures/assert_attribution.sql b/packages/jwt-claims/verify/schemas/jwt_private/procedures/assert_attribution.sql new file mode 100644 index 000000000..b6491e870 --- /dev/null +++ b/packages/jwt-claims/verify/schemas/jwt_private/procedures/assert_attribution.sql @@ -0,0 +1,7 @@ +-- Verify schemas/jwt_private/procedures/assert_attribution on pg + +BEGIN; + +SELECT assert_function('jwt_private.assert_attribution(uuid, uuid, text)'::regprocedure); + +ROLLBACK; diff --git a/packages/jwt-claims/verify/schemas/jwt_private/procedures/current_entity_id.sql b/packages/jwt-claims/verify/schemas/jwt_private/procedures/current_entity_id.sql new file mode 100644 index 000000000..fe0ad1026 --- /dev/null +++ b/packages/jwt-claims/verify/schemas/jwt_private/procedures/current_entity_id.sql @@ -0,0 +1,14 @@ +-- Verify schemas/jwt_private/procedures/current_entity_id on pg + +BEGIN; + +SELECT 1 / CASE WHEN EXISTS ( + SELECT 1 + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'jwt_private' + AND p.proname = 'current_entity_id' + AND p.proargtypes = '' +) THEN 1 ELSE 0 END; + +ROLLBACK; diff --git a/packages/jwt-claims/verify/schemas/jwt_private/procedures/current_entity_type.sql b/packages/jwt-claims/verify/schemas/jwt_private/procedures/current_entity_type.sql new file mode 100644 index 000000000..2a61caea3 --- /dev/null +++ b/packages/jwt-claims/verify/schemas/jwt_private/procedures/current_entity_type.sql @@ -0,0 +1,14 @@ +-- Verify schemas/jwt_private/procedures/current_entity_type on pg + +BEGIN; + +SELECT 1 / CASE WHEN EXISTS ( + SELECT 1 + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'jwt_private' + AND p.proname = 'current_entity_type' + AND p.proargtypes = '' +) THEN 1 ELSE 0 END; + +ROLLBACK; diff --git a/packages/jwt-claims/verify/schemas/jwt_private/procedures/require_database_id.sql b/packages/jwt-claims/verify/schemas/jwt_private/procedures/require_database_id.sql new file mode 100644 index 000000000..756d16d24 --- /dev/null +++ b/packages/jwt-claims/verify/schemas/jwt_private/procedures/require_database_id.sql @@ -0,0 +1,7 @@ +-- Verify schemas/jwt_private/procedures/require_database_id on pg + +BEGIN; + +SELECT assert_function('jwt_private.require_database_id()'::regprocedure); + +ROLLBACK; diff --git a/packages/jwt-claims/verify/schemas/jwt_private/procedures/require_entity_id.sql b/packages/jwt-claims/verify/schemas/jwt_private/procedures/require_entity_id.sql new file mode 100644 index 000000000..a031721dc --- /dev/null +++ b/packages/jwt-claims/verify/schemas/jwt_private/procedures/require_entity_id.sql @@ -0,0 +1,7 @@ +-- Verify schemas/jwt_private/procedures/require_entity_id on pg + +BEGIN; + +SELECT assert_function('jwt_private.require_entity_id()'::regprocedure); + +ROLLBACK; diff --git a/packages/jwt-claims/verify/schemas/jwt_private/procedures/require_entity_type.sql b/packages/jwt-claims/verify/schemas/jwt_private/procedures/require_entity_type.sql new file mode 100644 index 000000000..321411d4d --- /dev/null +++ b/packages/jwt-claims/verify/schemas/jwt_private/procedures/require_entity_type.sql @@ -0,0 +1,7 @@ +-- Verify schemas/jwt_private/procedures/require_entity_type on pg + +BEGIN; + +SELECT assert_function('jwt_private.require_entity_type()'::regprocedure); + +ROLLBACK; diff --git a/packages/jwt-claims/verify/schemas/jwt_public/procedures/require_user_id.sql b/packages/jwt-claims/verify/schemas/jwt_public/procedures/require_user_id.sql new file mode 100644 index 000000000..671213a9c --- /dev/null +++ b/packages/jwt-claims/verify/schemas/jwt_public/procedures/require_user_id.sql @@ -0,0 +1,7 @@ +-- Verify schemas/jwt_public/procedures/require_user_id on pg + +BEGIN; + +SELECT assert_function('jwt_public.require_user_id()'::regprocedure); + +ROLLBACK; diff --git a/packages/metaschema-modules/__tests__/__snapshots__/modules.test.ts.snap b/packages/metaschema-modules/__tests__/__snapshots__/modules.test.ts.snap index 38ded3134..8feb32b7b 100644 --- a/packages/metaschema-modules/__tests__/__snapshots__/modules.test.ts.snap +++ b/packages/metaschema-modules/__tests__/__snapshots__/modules.test.ts.snap @@ -158,7 +158,7 @@ exports[`db_meta_modules should verify module table structures have database_id exports[`db_meta_modules should verify module tables have proper foreign key relationships 1`] = ` { - "constraintCount": 551, + "constraintCount": 558, "foreignTables": [ "catalog_module", "database", diff --git a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/agent_module/table.sql b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/agent_module/table.sql index ddd06ca70..c4326dd5a 100644 --- a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/agent_module/table.sql +++ b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/agent_module/table.sql @@ -30,6 +30,7 @@ CREATE TABLE metaschema_modules_public.agent_module ( agent_table_id uuid DEFAULT NULL, persona_table_id uuid DEFAULT NULL, resource_table_id uuid DEFAULT NULL, + resource_repository_table_id uuid DEFAULT NULL, run_table_id uuid DEFAULT NULL, event_table_id uuid DEFAULT NULL, workspace_table_id uuid DEFAULT NULL, @@ -43,6 +44,7 @@ CREATE TABLE metaschema_modules_public.agent_module ( agent_table_name text NOT NULL DEFAULT 'agent', persona_table_name text NOT NULL DEFAULT 'agent_persona', resource_table_name text NOT NULL DEFAULT 'agent_resource', + resource_repository_table_name text NOT NULL DEFAULT 'agent_resource_repository', run_table_name text NOT NULL DEFAULT 'agent_run', event_table_name text NOT NULL DEFAULT 'agent_event', workspace_table_name text NOT NULL DEFAULT 'agent_run_workspace', @@ -51,6 +53,12 @@ CREATE TABLE metaschema_modules_public.agent_module ( has_plans boolean NOT NULL DEFAULT false, has_resources boolean NOT NULL DEFAULT false, has_agents boolean NOT NULL DEFAULT false, + -- Attaches resources to the repositories they apply to, through a junction + -- table. Requires a repository module at this same scope and has_resources: + -- with no repository catalog to point at there is nothing to attach to, and + -- the junction is simply not created. Same scope only, as everywhere else — a + -- pointer into another scope's catalog is not adjudicable by any policy. + has_repository_resources boolean NOT NULL DEFAULT false, -- The coding-agent execution surface: runs and their append-only transcripts. -- Off by default, so a conversation-only install provisions exactly what it did -- before this flag existed. @@ -111,6 +119,7 @@ CREATE TABLE metaschema_modules_public.agent_module ( CONSTRAINT agent_module_agent_table_fkey FOREIGN KEY (agent_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, CONSTRAINT agent_module_persona_table_fkey FOREIGN KEY (persona_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, CONSTRAINT agent_module_resource_table_fkey FOREIGN KEY (resource_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, + CONSTRAINT agent_module_resource_repository_table_fkey FOREIGN KEY (resource_repository_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, CONSTRAINT agent_module_run_table_fkey FOREIGN KEY (run_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, CONSTRAINT agent_module_event_table_fkey FOREIGN KEY (event_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, CONSTRAINT agent_module_workspace_table_fkey FOREIGN KEY (workspace_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, @@ -126,6 +135,7 @@ CREATE INDEX agent_module_persona_table_id_idx ON metaschema_modules_public.agen CREATE INDEX agent_module_plan_table_id_idx ON metaschema_modules_public.agent_module ( plan_table_id ); CREATE INDEX agent_module_prompts_table_id_idx ON metaschema_modules_public.agent_module ( prompts_table_id ); CREATE INDEX agent_module_resource_table_id_idx ON metaschema_modules_public.agent_module ( resource_table_id ); +CREATE INDEX agent_module_resource_repository_table_id_idx ON metaschema_modules_public.agent_module ( resource_repository_table_id ); CREATE INDEX agent_module_run_table_id_idx ON metaschema_modules_public.agent_module ( run_table_id ); CREATE INDEX agent_module_event_table_id_idx ON metaschema_modules_public.agent_module ( event_table_id ); CREATE INDEX agent_module_workspace_table_id_idx ON metaschema_modules_public.agent_module ( workspace_table_id ); @@ -144,6 +154,7 @@ COMMENT ON COLUMN metaschema_modules_public.agent_module.persona_table_id IS '@m COMMENT ON COLUMN metaschema_modules_public.agent_module.plan_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.agent_module.prompts_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.agent_module.resource_table_id IS '@module_table'; +COMMENT ON COLUMN metaschema_modules_public.agent_module.resource_repository_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.agent_module.task_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.agent_module.thread_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.agent_module.run_table_id IS '@module_table'; diff --git a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/app_module/table.sql b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/app_module/table.sql index 6f5b8ef4b..85db607c8 100644 --- a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/app_module/table.sql +++ b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/app_module/table.sql @@ -33,6 +33,10 @@ CREATE TABLE metaschema_modules_public.app_module ( -- Table names (input to the generator) apps_table_name text NOT NULL DEFAULT 'apps', app_components_table_name text NOT NULL DEFAULT 'app_components', + -- Mobile-store identity owned by the app (platform, bundle id / package + -- name, team id, signing fingerprints, store URL). A site's app-link + -- association names a row here rather than restating it per host. + app_store_identities_table_name text NOT NULL DEFAULT 'app_store_identities', -- API routing (get-or-create: if set, schema is added to this API) api_name text, diff --git a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/billing_provider_module/table.sql b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/billing_provider_module/table.sql index 68558e4f6..1caad4c3d 100644 --- a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/billing_provider_module/table.sql +++ b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/billing_provider_module/table.sql @@ -43,6 +43,9 @@ CREATE TABLE metaschema_modules_public.billing_provider_module ( billing_invoices_table_id uuid NOT NULL DEFAULT uuid_nil(), billing_invoices_table_name text NOT NULL DEFAULT '', + billing_disputes_table_id uuid NOT NULL DEFAULT uuid_nil(), + billing_disputes_table_name text NOT NULL DEFAULT '', + -- Generated functions process_billing_event_function text NOT NULL DEFAULT '', record_refund_function text NOT NULL DEFAULT '', @@ -51,6 +54,27 @@ CREATE TABLE metaschema_modules_public.billing_provider_module ( -- the provider's subscription item, which only this module knows about. list_pending_usage_sync_function text NOT NULL DEFAULT '', mark_usage_synced_function text NOT NULL DEFAULT '', + -- Reconcile seams: the system-only read/write path the billing sync workers + -- use to mirror provider objects into the mapping tables. + get_billing_customer_function text NOT NULL DEFAULT '', + upsert_billing_customer_function text NOT NULL DEFAULT '', + get_billing_product_function text NOT NULL DEFAULT '', + upsert_billing_product_function text NOT NULL DEFAULT '', + get_billing_price_function text NOT NULL DEFAULT '', + upsert_billing_price_function text NOT NULL DEFAULT '', + get_billing_subscription_function text NOT NULL DEFAULT '', + upsert_billing_subscription_function text NOT NULL DEFAULT '', + sweep_overdue_subscriptions_function text NOT NULL DEFAULT '', + -- Read seams over the plan tables the module points at: which active pricing + -- a plan bills through, and which plan an overdue entity falls back to. + get_active_plan_pricing_function text NOT NULL DEFAULT '', + get_fallback_free_plan_function text NOT NULL DEFAULT '', + -- Disputes are recorded separately from refunds: the bank opens them, they + -- are adjudicated, and credits are clawed back only on a lost outcome. + record_dispute_function text NOT NULL DEFAULT '', + -- The webhook side of a completed purchase: the system-only seam that moves + -- an entity onto the plan a verified provider event names. + activate_plan_subscription_function text NOT NULL DEFAULT '', prefix text NULL, @@ -68,6 +92,7 @@ CREATE TABLE metaschema_modules_public.billing_provider_module ( CONSTRAINT billing_webhook_events_table_fkey FOREIGN KEY (billing_webhook_events_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, CONSTRAINT billing_refunds_table_fkey FOREIGN KEY (billing_refunds_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, CONSTRAINT billing_invoices_table_fkey FOREIGN KEY (billing_invoices_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, + CONSTRAINT billing_disputes_table_fkey FOREIGN KEY (billing_disputes_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, CONSTRAINT products_table_fkey FOREIGN KEY (products_table_id) REFERENCES metaschema_public.table (id) ON DELETE SET NULL, CONSTRAINT prices_table_fkey FOREIGN KEY (prices_table_id) REFERENCES metaschema_public.table (id) ON DELETE SET NULL, CONSTRAINT subscriptions_table_fkey FOREIGN KEY (subscriptions_table_id) REFERENCES metaschema_public.table (id) ON DELETE SET NULL, @@ -81,6 +106,7 @@ CREATE INDEX billing_provider_module_billing_subscriptions_table_id_idx ON metas CREATE INDEX billing_provider_module_billing_webhook_events_table_id_idx ON metaschema_modules_public.billing_provider_module ( billing_webhook_events_table_id ); CREATE INDEX billing_provider_module_billing_refunds_table_id_idx ON metaschema_modules_public.billing_provider_module ( billing_refunds_table_id ); CREATE INDEX billing_provider_module_billing_invoices_table_id_idx ON metaschema_modules_public.billing_provider_module ( billing_invoices_table_id ); +CREATE INDEX billing_provider_module_billing_disputes_table_id_idx ON metaschema_modules_public.billing_provider_module ( billing_disputes_table_id ); CREATE INDEX billing_provider_module_prices_table_id_idx ON metaschema_modules_public.billing_provider_module ( prices_table_id ); CREATE INDEX billing_provider_module_products_table_id_idx ON metaschema_modules_public.billing_provider_module ( products_table_id ); CREATE INDEX billing_provider_module_subscriptions_table_id_idx ON metaschema_modules_public.billing_provider_module ( subscriptions_table_id ); @@ -92,6 +118,7 @@ CREATE INDEX billing_provider_module_schema_id_idx ON metaschema_modules_public. -- metaschema_modules_private.tg_module_install_provenance attributes to this -- install, keyed by the role name in the column. COMMENT ON COLUMN metaschema_modules_public.billing_provider_module.billing_customers_table_id IS '@module_table'; +COMMENT ON COLUMN metaschema_modules_public.billing_provider_module.billing_disputes_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.billing_provider_module.billing_invoices_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.billing_provider_module.billing_prices_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.billing_provider_module.billing_products_table_id IS '@module_table'; diff --git a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/catalog_module/table.sql b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/catalog_module/table.sql index 1fad9c05a..a347b912e 100644 --- a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/catalog_module/table.sql +++ b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/catalog_module/table.sql @@ -5,7 +5,7 @@ BEGIN; -- Typed catalog module configuration: one row per database installs the typed --- catalog tables (catalog_private.domains / apis / sites / namespaces / +-- catalog tables (catalog_private.domains / managed_domains / apis / sites / namespaces / -- functions / resources / resource_definitions / resource_installations / -- apps / buckets / images / redirects / bindings). The catalog is -- a system projection surface holding ALL scopes of each type; scoped source @@ -23,6 +23,7 @@ CREATE TABLE metaschema_modules_public.catalog_module ( -- Generated table IDs (populated by the generator) domains_table_id uuid NOT NULL DEFAULT uuid_nil(), + managed_domains_table_id uuid NOT NULL DEFAULT uuid_nil(), apis_table_id uuid NOT NULL DEFAULT uuid_nil(), sites_table_id uuid NOT NULL DEFAULT uuid_nil(), namespaces_table_id uuid NOT NULL DEFAULT uuid_nil(), @@ -38,10 +39,12 @@ CREATE TABLE metaschema_modules_public.catalog_module ( sites_error_pages_table_id uuid NOT NULL DEFAULT uuid_nil(), sites_app_links_table_id uuid NOT NULL DEFAULT uuid_nil(), sites_deep_links_table_id uuid NOT NULL DEFAULT uuid_nil(), + app_store_identities_table_id uuid NOT NULL DEFAULT uuid_nil(), redirects_table_id uuid NOT NULL DEFAULT uuid_nil(), -- Table names (inputs to the generator; stable load-bearing contracts) domains_table_name text NOT NULL DEFAULT 'domains', + managed_domains_table_name text NOT NULL DEFAULT 'managed_domains', apis_table_name text NOT NULL DEFAULT 'apis', sites_table_name text NOT NULL DEFAULT 'sites', namespaces_table_name text NOT NULL DEFAULT 'namespaces', @@ -57,6 +60,7 @@ CREATE TABLE metaschema_modules_public.catalog_module ( sites_error_pages_table_name text NOT NULL DEFAULT 'sites_error_pages', sites_app_links_table_name text NOT NULL DEFAULT 'sites_app_links', sites_deep_links_table_name text NOT NULL DEFAULT 'sites_deep_links', + app_store_identities_table_name text NOT NULL DEFAULT 'app_store_identities', redirects_table_name text NOT NULL DEFAULT 'redirects', -- API routing (get-or-create: if set, schema is added to this API) @@ -91,6 +95,10 @@ CREATE TABLE metaschema_modules_public.catalog_module ( FOREIGN KEY (domains_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, + CONSTRAINT catalog_module_managed_domains_table_fkey + FOREIGN KEY (managed_domains_table_id) + REFERENCES metaschema_public.table (id) + ON DELETE CASCADE, CONSTRAINT catalog_module_apis_table_fkey FOREIGN KEY (apis_table_id) REFERENCES metaschema_public.table (id) @@ -151,6 +159,10 @@ CREATE TABLE metaschema_modules_public.catalog_module ( FOREIGN KEY (sites_deep_links_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, + CONSTRAINT catalog_module_app_store_identities_table_fkey + FOREIGN KEY (app_store_identities_table_id) + REFERENCES metaschema_public.table (id) + ON DELETE CASCADE, CONSTRAINT catalog_module_redirects_table_fkey FOREIGN KEY (redirects_table_id) REFERENCES metaschema_public.table (id) @@ -174,8 +186,10 @@ CREATE INDEX catalog_module_sites_web_config_table_id_idx ON metaschema_modules_ CREATE INDEX catalog_module_sites_error_pages_table_id_idx ON metaschema_modules_public.catalog_module ( sites_error_pages_table_id ); CREATE INDEX catalog_module_sites_app_links_table_id_idx ON metaschema_modules_public.catalog_module ( sites_app_links_table_id ); CREATE INDEX catalog_module_sites_deep_links_table_id_idx ON metaschema_modules_public.catalog_module ( sites_deep_links_table_id ); +CREATE INDEX catalog_module_app_store_identities_table_id_idx ON metaschema_modules_public.catalog_module ( app_store_identities_table_id ); CREATE INDEX catalog_module_redirects_table_id_idx ON metaschema_modules_public.catalog_module ( redirects_table_id ); CREATE INDEX catalog_module_domains_table_id_idx ON metaschema_modules_public.catalog_module ( domains_table_id ); +CREATE INDEX catalog_module_managed_domains_table_id_idx ON metaschema_modules_public.catalog_module ( managed_domains_table_id ); CREATE INDEX catalog_module_entity_table_id_idx ON metaschema_modules_public.catalog_module ( entity_table_id ); CREATE INDEX catalog_module_functions_table_id_idx ON metaschema_modules_public.catalog_module ( functions_table_id ); CREATE INDEX catalog_module_namespaces_table_id_idx ON metaschema_modules_public.catalog_module ( namespaces_table_id ); @@ -194,6 +208,7 @@ COMMENT ON COLUMN metaschema_modules_public.catalog_module.apps_table_id IS '@mo COMMENT ON COLUMN metaschema_modules_public.catalog_module.bindings_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.catalog_module.buckets_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.catalog_module.domains_table_id IS '@module_table'; +COMMENT ON COLUMN metaschema_modules_public.catalog_module.managed_domains_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.catalog_module.functions_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.catalog_module.images_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.catalog_module.namespaces_table_id IS '@module_table'; @@ -203,6 +218,7 @@ COMMENT ON COLUMN metaschema_modules_public.catalog_module.redirects_table_id IS COMMENT ON COLUMN metaschema_modules_public.catalog_module.resources_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.catalog_module.sites_app_links_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.catalog_module.sites_deep_links_table_id IS '@module_table'; +COMMENT ON COLUMN metaschema_modules_public.catalog_module.app_store_identities_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.catalog_module.sites_error_pages_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.catalog_module.sites_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.catalog_module.sites_web_config_table_id IS '@module_table'; diff --git a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/function_module/table.sql b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/function_module/table.sql index 33b04c3cf..42bcc5ccc 100644 --- a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/function_module/table.sql +++ b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/function_module/table.sql @@ -52,6 +52,11 @@ CREATE TABLE metaschema_modules_public.function_module ( -- Naming-only: instances are unique per (database_id, scope). prefix text NOT NULL DEFAULT '', + -- Which of the scope's storage planes a capability binding may target + -- (bucket_id FKs that plane's buckets table). Storage is the one plane kind + -- a scope may carry several of, so the binding names its key. + storage_key text NOT NULL DEFAULT 'default', + -- Entity table for RLS (NULL for app-level functions, entity table for entity-scoped functions) entity_table_id uuid NULL, diff --git a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/pages_module/table.sql b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/pages_module/table.sql index 7fc4515b5..be1580a77 100644 --- a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/pages_module/table.sql +++ b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/pages_module/table.sql @@ -34,6 +34,25 @@ CREATE TABLE metaschema_modules_public.pages_module ( -- metadata, themes — so it is named for the site, not for pages. store_name_prefix text NOT NULL DEFAULT 'site:', + -- Generated release-manifest reader function name (populated by the + -- generator when the site surface provisions a releases head; NULL + -- otherwise). Read as a fact — never re-derived from the prefix. + release_manifest_function_name text, + + -- Generated preview-commit reader function name (populated by the + -- generator when the site surface provisions a releases head; NULL + -- otherwise). This is the two-argument reader (site_id, name). + -- Read as a fact — never re-derived from the prefix. + preview_commit_function_name text, + + -- Generated preview setter function name (populated by the generator + -- when the site surface provisions a releases head; NULL otherwise). + -- This is the three-argument writer (site_id, name, commit_id). + -- Read as a fact — never re-derived from the prefix. + preview_set_function_name text, + preview_token_mint_function_name text, + preview_token_verifier_function_name text, + api_name text, private_api_name text, entity_table_id uuid NULL, diff --git a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/repository_module/table.sql b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/repository_module/table.sql index dd90361e3..24cf4f375 100644 --- a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/repository_module/table.sql +++ b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/repository_module/table.sql @@ -24,6 +24,7 @@ CREATE TABLE metaschema_modules_public.repository_module ( -- Generated table IDs (populated by the generator) repositories_table_id uuid NOT NULL DEFAULT uuid_nil(), repository_events_table_id uuid NOT NULL DEFAULT uuid_nil(), + repository_required_checks_table_id uuid NOT NULL DEFAULT uuid_nil(), workflows_table_id uuid NOT NULL DEFAULT uuid_nil(), builds_table_id uuid NOT NULL DEFAULT uuid_nil(), build_steps_table_id uuid NOT NULL DEFAULT uuid_nil(), @@ -36,6 +37,7 @@ CREATE TABLE metaschema_modules_public.repository_module ( -- Table names (input to the generator) repositories_table_name text NOT NULL DEFAULT 'repositories', repository_events_table_name text NOT NULL DEFAULT 'repository_events', + repository_required_checks_table_name text NOT NULL DEFAULT 'repository_required_checks', workflows_table_name text NOT NULL DEFAULT 'repository_workflows', builds_table_name text NOT NULL DEFAULT 'builds', build_steps_table_name text NOT NULL DEFAULT 'build_steps', @@ -94,6 +96,7 @@ CREATE TABLE metaschema_modules_public.repository_module ( CONSTRAINT repository_module_private_schema_fkey FOREIGN KEY (private_schema_id) REFERENCES metaschema_public.schema (id) ON DELETE CASCADE, CONSTRAINT repository_module_repositories_table_fkey FOREIGN KEY (repositories_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, CONSTRAINT repository_module_events_table_fkey FOREIGN KEY (repository_events_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, + CONSTRAINT repository_module_required_checks_table_fkey FOREIGN KEY (repository_required_checks_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, CONSTRAINT repository_module_workflows_table_fkey FOREIGN KEY (workflows_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, CONSTRAINT repository_module_builds_table_fkey FOREIGN KEY (builds_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, CONSTRAINT repository_module_build_steps_table_fkey FOREIGN KEY (build_steps_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, @@ -112,6 +115,7 @@ CREATE UNIQUE INDEX repository_module_unique_scope ON metaschema_modules_public. CREATE INDEX repository_module_entity_table_id_idx ON metaschema_modules_public.repository_module ( entity_table_id ); CREATE INDEX repository_module_repositories_table_id_idx ON metaschema_modules_public.repository_module ( repositories_table_id ); CREATE INDEX repository_module_repository_events_table_id_idx ON metaschema_modules_public.repository_module ( repository_events_table_id ); +CREATE INDEX repository_module_repository_required_checks_table_id_idx ON metaschema_modules_public.repository_module ( repository_required_checks_table_id ); CREATE INDEX repository_module_workflows_table_id_idx ON metaschema_modules_public.repository_module ( workflows_table_id ); CREATE INDEX repository_module_builds_table_id_idx ON metaschema_modules_public.repository_module ( builds_table_id ); CREATE INDEX repository_module_build_steps_table_id_idx ON metaschema_modules_public.repository_module ( build_steps_table_id ); diff --git a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/resource_module/table.sql b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/resource_module/table.sql index 82c9711ba..362c0cc3d 100644 --- a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/resource_module/table.sql +++ b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/resource_module/table.sql @@ -36,6 +36,10 @@ CREATE TABLE metaschema_modules_public.resource_module ( -- than by namespace_module because its keys are the namespace and the -- installed registry, which only exist together in this scope. registry_bindings_table_id uuid NOT NULL DEFAULT uuid_nil(), + -- Which BuildKit installation a namespace builds on, per lane. Generated + -- here because its namespace FK is local while the installation may be + -- owned by another resource scope. + builder_bindings_table_id uuid NOT NULL DEFAULT uuid_nil(), -- Table names (input to the generator — bare names without scope prefix). -- The trigger prepends the scope prefix automatically. @@ -47,6 +51,7 @@ CREATE TABLE metaschema_modules_public.resource_module ( resource_usage_summary_table_name text NOT NULL DEFAULT 'resource_usage_summary', resource_installations_table_name text NOT NULL DEFAULT 'resource_installations', registry_bindings_table_name text NOT NULL DEFAULT 'registry_bindings', + builder_bindings_table_name text NOT NULL DEFAULT 'builder_bindings', -- Generated functions (populated by the generator) rollup_resource_usage_summary_function text NOT NULL DEFAULT '', @@ -105,6 +110,7 @@ CREATE TABLE metaschema_modules_public.resource_module ( CONSTRAINT resource_module_usage_summary_table_fkey FOREIGN KEY (resource_usage_summary_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, CONSTRAINT resource_module_installations_table_fkey FOREIGN KEY (resource_installations_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, CONSTRAINT resource_module_registry_bindings_table_fkey FOREIGN KEY (registry_bindings_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, + CONSTRAINT resource_module_builder_bindings_table_fkey FOREIGN KEY (builder_bindings_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, CONSTRAINT resource_module_merkle_store_module_fkey FOREIGN KEY (merkle_store_module_id) REFERENCES metaschema_modules_public.merkle_store_module (id) ON DELETE SET NULL, CONSTRAINT resource_module_entity_table_fkey FOREIGN KEY (entity_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, CONSTRAINT resource_module_namespace_module_fkey FOREIGN KEY (namespace_module_id) REFERENCES metaschema_modules_public.namespace_module (id) ON DELETE SET NULL @@ -116,6 +122,7 @@ CREATE INDEX resource_module_resource_definitions_table_id_idx ON metaschema_mod CREATE INDEX resource_module_entity_table_id_idx ON metaschema_modules_public.resource_module ( entity_table_id ); CREATE INDEX resource_module_resource_events_table_id_idx ON metaschema_modules_public.resource_module ( resource_events_table_id ); CREATE INDEX resource_module_resource_installations_table_id_idx ON metaschema_modules_public.resource_module ( resource_installations_table_id ); +CREATE INDEX resource_module_builder_bindings_table_id_idx ON metaschema_modules_public.resource_module ( builder_bindings_table_id ); CREATE INDEX resource_module_registry_bindings_table_id_idx ON metaschema_modules_public.resource_module ( registry_bindings_table_id ); CREATE INDEX resource_module_resources_table_id_idx ON metaschema_modules_public.resource_module ( resources_table_id ); CREATE INDEX resource_module_resource_status_checks_table_id_idx ON metaschema_modules_public.resource_module ( resource_status_checks_table_id ); @@ -134,6 +141,7 @@ COMMENT ON COLUMN metaschema_modules_public.resource_module.resource_definitions COMMENT ON COLUMN metaschema_modules_public.resource_module.resource_events_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.resource_module.resource_installations_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.resource_module.registry_bindings_table_id IS '@module_table'; +COMMENT ON COLUMN metaschema_modules_public.resource_module.builder_bindings_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.resource_module.resource_status_checks_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.resource_module.resource_usage_log_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.resource_module.resource_usage_summary_table_id IS '@module_table'; diff --git a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/route_module/table.sql b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/route_module/table.sql index 1166b42cf..ccae8aca1 100644 --- a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/route_module/table.sql +++ b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/route_module/table.sql @@ -14,6 +14,14 @@ CREATE TABLE metaschema_modules_public.route_module ( -- trigger via metaschema_generators.scope_key_column(scope, key). entity_field text, + -- Column on the generated routes table carrying the site whose surface a + -- route serves, recorded by the generator when it creates that field. It is + -- read the same way entity_field is: a portable install engine consults the + -- registration and stamps the column it names, so the fact "a route can say + -- which site it renders as" travels with the plane instead of living in the + -- generated verbs. NULL for a routing plane that carries no such column. + serving_site_field text, + -- Schema references (if uuid_nil, resolved from schema name or default) schema_id uuid NOT NULL DEFAULT uuid_nil(), private_schema_id uuid NOT NULL DEFAULT uuid_nil(), @@ -64,6 +72,13 @@ CREATE TABLE metaschema_modules_public.route_module ( -- Table name prefix. Auto-derived from scope by the trigger when empty. prefix text NOT NULL DEFAULT '', + -- Which of the scope's storage planes a route may target + -- (routes.target_bucket_id FKs that plane's buckets table). A scope may + -- carry several storage planes, each with its own semantic key, so the + -- binding is named rather than guessed; 'default' is the plane a blueprint + -- installs when it declares no storage keys. + storage_key text NOT NULL DEFAULT 'default', + -- Entity table for RLS (NULL for non-entity scopes) entity_table_id uuid NULL, diff --git a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/secure_table_provision/table.sql b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/secure_table_provision/table.sql index ca5b36e8b..59d1efc8e 100644 --- a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/secure_table_provision/table.sql +++ b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/secure_table_provision/table.sql @@ -15,6 +15,10 @@ CREATE TABLE metaschema_modules_public.secure_table_provision ( table_name text DEFAULT NULL, + module jsonb DEFAULT NULL, + + owns jsonb NOT NULL DEFAULT '[]', + nodes jsonb NOT NULL DEFAULT '[]', use_rls boolean NOT NULL DEFAULT true, @@ -33,7 +37,7 @@ CREATE TABLE metaschema_modules_public.secure_table_provision ( ); COMMENT ON TABLE metaschema_modules_public.secure_table_provision IS - 'Provisions security, fields, grants, and policies onto a table. Each row can independently: (1) create fields via nodes[] array (supporting multiple Data* modules per row), (2) grant privileges via grants[] array (supporting per-role privilege targeting), (3) create RLS policies via policies[] array (supporting multiple Authz* policies per row). Multiple rows can target the same table to compose different concerns. All three concerns are optional and independent.'; + 'Provisions security, fields, grants, and policies onto a table. Each row can independently: (1) create fields via nodes[] array (supporting multiple Data* modules per row), (2) grant privileges via grants[] array (supporting per-role privilege targeting), (3) create RLS policies via policies[] array (supporting multiple Authz* policies per row). Multiple rows can target the same table to compose different concerns. All three concerns are optional and independent. The target table is addressed by table_id, by table_name, or symbolically by a module reference in module. A row that lists a concern in owns[] replaces that concern on the target table instead of composing with what is already there.'; COMMENT ON COLUMN metaschema_modules_public.secure_table_provision.id IS 'Unique identifier for this provision row.'; @@ -50,6 +54,12 @@ COMMENT ON COLUMN metaschema_modules_public.secure_table_provision.table_id IS COMMENT ON COLUMN metaschema_modules_public.secure_table_provision.table_name IS 'Name of the target table. Used to create or look up the table when table_id is not provided. If omitted, it is backfilled from the resolved table.'; +COMMENT ON COLUMN metaschema_modules_public.secure_table_provision.module IS + 'Module reference naming a module-generated target table symbolically, instead of by table_id or table_name: a jsonb object with keys "type" (text, required — the module type, e.g. "image"), "table" (text, required — the module''s own table key, e.g. "registries"), "scope" (text, optional — the install scope) and "prefix" (text, optional — disambiguates multiple installs of the same module). Resolved through metaschema_modules_private.resolve_module_table(), so it raises the same errors blueprint module references do (BLUEPRINT_MODULE_REF_INVALID, BLUEPRINT_MODULE_NOT_INSTALLED, BLUEPRINT_MODULE_REF_AMBIGUOUS, BLUEPRINT_MODULE_TABLE_UNKNOWN). Mutually exclusive with table_name and with an explicit table_id. Example: {"type":"image","scope":"org","table":"registries"}. Defaults to NULL.'; + +COMMENT ON COLUMN metaschema_modules_public.secure_table_provision.owns IS + 'Security concerns this row owns on the target table, as a jsonb array of "grants" and/or "policies". A listed concern is replaced: the target table''s existing grants (or its non-derived policies) are dropped before this row''s grants[] (or policies[]) are applied, so the row''s array is the table''s whole set — this is how a module-generated table''s default security is superseded rather than layered on. An unlisted concern composes, which is the default and the historical behavior. A concern may only be owned when this row supplies a non-empty array for it; owning a concern with nothing to install would leave the table with RLS enabled and no policy, and raises instead. Example: ["policies","grants"]. Defaults to ''[]'' (compose everything).'; + COMMENT ON COLUMN metaschema_modules_public.secure_table_provision.nodes IS 'Array of node objects to apply to the table. Each element is a jsonb object with a required "$type" key (one of: DataId, DataDirectOwner, DataEntityMembership, DataOwnershipInEntity, DataTimestamps, DataPeoplestamps, DataPublishable, DataSoftDelete, DataEmbedding, DataFullTextSearch, DataSlug, etc.) and an optional "data" key containing generator-specific configuration. Supports multiple nodes per row, matching the blueprint definition format. Example: [{"$type": "DataId"}, {"$type": "DataTimestamps"}, {"$type": "DataDirectOwner", "data": {"owner_field_name": "author_id"}}]. Defaults to ''[]'' (no node processing).'; diff --git a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/site_surface_module/table.sql b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/site_surface_module/table.sql index d3e542c12..abc3b0493 100644 --- a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/site_surface_module/table.sql +++ b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/site_surface_module/table.sql @@ -32,6 +32,7 @@ CREATE TABLE metaschema_modules_public.site_surface_module ( site_metadata_table_id uuid NOT NULL DEFAULT uuid_nil(), site_modules_table_id uuid NOT NULL DEFAULT uuid_nil(), site_themes_table_id uuid NOT NULL DEFAULT uuid_nil(), + site_releases_table_id uuid NOT NULL DEFAULT uuid_nil(), site_app_links_table_id uuid NOT NULL DEFAULT uuid_nil(), site_deep_links_table_id uuid NOT NULL DEFAULT uuid_nil(), -- Serving-config companions (the distribution tier's typed behavior): @@ -45,6 +46,7 @@ CREATE TABLE metaschema_modules_public.site_surface_module ( site_metadata_table_name text NOT NULL DEFAULT 'site_metadata', site_modules_table_name text NOT NULL DEFAULT 'site_modules', site_themes_table_name text NOT NULL DEFAULT 'site_themes', + site_releases_table_name text NOT NULL DEFAULT 'site_releases', site_app_links_table_name text NOT NULL DEFAULT 'site_app_links', site_deep_links_table_name text NOT NULL DEFAULT 'site_deep_links', site_web_config_table_name text NOT NULL DEFAULT 'site_web_config', @@ -60,6 +62,13 @@ CREATE TABLE metaschema_modules_public.site_surface_module ( -- Table name prefix. Auto-derived from scope by the trigger when empty. prefix text NOT NULL DEFAULT '', + -- Which of the scope's storage planes backs a site (sites.bucket_id FKs + -- that plane's buckets table). A scope may carry several storage planes, + -- each with its own semantic key, so the binding is named rather than + -- guessed; 'default' is the plane a blueprint installs when it declares no + -- storage keys. + storage_key text NOT NULL DEFAULT 'default', + -- Entity table for RLS (NULL for non-entity scopes) entity_table_id uuid NULL, @@ -104,6 +113,10 @@ CREATE TABLE metaschema_modules_public.site_surface_module ( FOREIGN KEY (site_themes_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, + CONSTRAINT site_module_site_releases_table_fkey + FOREIGN KEY (site_releases_table_id) + REFERENCES metaschema_public.table (id) + ON DELETE CASCADE, CONSTRAINT site_module_site_app_links_table_fkey FOREIGN KEY (site_app_links_table_id) REFERENCES metaschema_public.table (id) @@ -132,6 +145,7 @@ CREATE INDEX site_surface_module_entity_table_id_idx ON metaschema_modules_publi CREATE INDEX site_surface_module_site_metadata_table_id_idx ON metaschema_modules_public.site_surface_module ( site_metadata_table_id ); CREATE INDEX site_surface_module_site_modules_table_id_idx ON metaschema_modules_public.site_surface_module ( site_modules_table_id ); CREATE INDEX site_surface_module_site_themes_table_id_idx ON metaschema_modules_public.site_surface_module ( site_themes_table_id ); +CREATE INDEX site_surface_module_site_releases_table_id_idx ON metaschema_modules_public.site_surface_module ( site_releases_table_id ); CREATE INDEX site_surface_module_site_app_links_table_id_idx ON metaschema_modules_public.site_surface_module ( site_app_links_table_id ); CREATE INDEX site_surface_module_site_deep_links_table_id_idx ON metaschema_modules_public.site_surface_module ( site_deep_links_table_id ); CREATE INDEX site_surface_module_site_web_config_table_id_idx ON metaschema_modules_public.site_surface_module ( site_web_config_table_id ); diff --git a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/storage_module/table.sql b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/storage_module/table.sql index 602577895..a0c819c12 100644 --- a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/storage_module/table.sql +++ b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/storage_module/table.sql @@ -35,9 +35,18 @@ CREATE TABLE metaschema_modules_public.storage_module ( scope text NOT NULL, -- Table name prefix. Auto-derived from scope by the trigger when empty. - -- Override to create multiple module instances at the same scope. + -- Naming only: it decides whether the tables are `buckets` or `org_buckets`. prefix text NOT NULL DEFAULT '', + -- Semantic identity of this plane WITHIN its scope, and the only thing a + -- consumer binds to. Storage is the one plane kind a scope may legitimately + -- have several of (a blueprint's entity type declares storage keys, e.g. + -- `default` plus `media`), so a consumer that references "the scope's + -- buckets table" — sites.bucket_id, routes.target_bucket_id — must name + -- WHICH plane it means. It names this key, never the prefix, which is a + -- table-naming artifact. + key text NOT NULL DEFAULT 'default', + -- Configurable security policies (NULL = use defaults based on scope). -- When provided, replaces the default policy set in apply_storage_security. -- Accepts a JSON array of policy objects: @@ -125,7 +134,14 @@ CREATE TABLE metaschema_modules_public.storage_module ( CONSTRAINT file_events_table_fkey FOREIGN KEY (file_events_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE ); --- Unique constraint: one storage module per database per scope per prefix. +-- Semantic identity: one storage plane per (database, scope, key). Every +-- sibling plane module (api_surface/site_surface/resource/function/domain/ +-- route/app) is UNIQUE (database_id, scope) because a scope has exactly one of +-- them; storage is the exception, so it carries an explicit key and consumers +-- bind to it. This is what makes the typed bucket references unambiguous +-- instead of "whichever plane the lookup happened to read first". +CREATE UNIQUE INDEX storage_module_unique_key ON metaschema_modules_public.storage_module ( database_id, scope, key ); +-- Physical identity: two planes in a scope cannot claim the same table names. CREATE UNIQUE INDEX storage_module_unique_scope ON metaschema_modules_public.storage_module ( database_id, scope, prefix ); CREATE INDEX storage_module_buckets_table_id_idx ON metaschema_modules_public.storage_module ( buckets_table_id ); CREATE INDEX storage_module_entity_table_id_idx ON metaschema_modules_public.storage_module ( entity_table_id ); diff --git a/packages/metaschema-modules/sql/metaschema-modules--0.43.2.bundle.tar.gz b/packages/metaschema-modules/sql/metaschema-modules--0.43.2.bundle.tar.gz index bbf50c339..ad5a66ed7 100644 Binary files a/packages/metaschema-modules/sql/metaschema-modules--0.43.2.bundle.tar.gz and b/packages/metaschema-modules/sql/metaschema-modules--0.43.2.bundle.tar.gz differ diff --git a/packages/metaschema-modules/sql/metaschema-modules--0.43.2.sql b/packages/metaschema-modules/sql/metaschema-modules--0.43.2.sql index 23edfdb42..54e2c5304 100644 --- a/packages/metaschema-modules/sql/metaschema-modules--0.43.2.sql +++ b/packages/metaschema-modules/sql/metaschema-modules--0.43.2.sql @@ -1434,6 +1434,8 @@ CREATE TABLE metaschema_modules_public.secure_table_provision ( schema_id uuid NOT NULL DEFAULT uuid_nil(), table_id uuid NOT NULL DEFAULT uuid_nil(), table_name text DEFAULT NULL, + module jsonb DEFAULT NULL, + owns jsonb NOT NULL DEFAULT '[]', nodes jsonb NOT NULL DEFAULT '[]', use_rls boolean NOT NULL DEFAULT true, fields jsonb[] NOT NULL DEFAULT '{}', @@ -1454,7 +1456,7 @@ CREATE TABLE metaschema_modules_public.secure_table_provision ( ON DELETE CASCADE ); -COMMENT ON TABLE metaschema_modules_public.secure_table_provision IS 'Provisions security, fields, grants, and policies onto a table. Each row can independently: (1) create fields via nodes[] array (supporting multiple Data* modules per row), (2) grant privileges via grants[] array (supporting per-role privilege targeting), (3) create RLS policies via policies[] array (supporting multiple Authz* policies per row). Multiple rows can target the same table to compose different concerns. All three concerns are optional and independent.'; +COMMENT ON TABLE metaschema_modules_public.secure_table_provision IS 'Provisions security, fields, grants, and policies onto a table. Each row can independently: (1) create fields via nodes[] array (supporting multiple Data* modules per row), (2) grant privileges via grants[] array (supporting per-role privilege targeting), (3) create RLS policies via policies[] array (supporting multiple Authz* policies per row). Multiple rows can target the same table to compose different concerns. All three concerns are optional and independent. The target table is addressed by table_id, by table_name, or symbolically by a module reference in module. A row that lists a concern in owns[] replaces that concern on the target table instead of composing with what is already there.'; COMMENT ON COLUMN metaschema_modules_public.secure_table_provision.id IS 'Unique identifier for this provision row.'; @@ -1466,6 +1468,10 @@ COMMENT ON COLUMN metaschema_modules_public.secure_table_provision.table_id IS ' COMMENT ON COLUMN metaschema_modules_public.secure_table_provision.table_name IS 'Name of the target table. Used to create or look up the table when table_id is not provided. If omitted, it is backfilled from the resolved table.'; +COMMENT ON COLUMN metaschema_modules_public.secure_table_provision.module IS 'Module reference naming a module-generated target table symbolically, instead of by table_id or table_name: a jsonb object with keys "type" (text, required — the module type, e.g. "image"), "table" (text, required — the module''s own table key, e.g. "registries"), "scope" (text, optional — the install scope) and "prefix" (text, optional — disambiguates multiple installs of the same module). Resolved through metaschema_modules_private.resolve_module_table(), so it raises the same errors blueprint module references do (BLUEPRINT_MODULE_REF_INVALID, BLUEPRINT_MODULE_NOT_INSTALLED, BLUEPRINT_MODULE_REF_AMBIGUOUS, BLUEPRINT_MODULE_TABLE_UNKNOWN). Mutually exclusive with table_name and with an explicit table_id. Example: {"type":"image","scope":"org","table":"registries"}. Defaults to NULL.'; + +COMMENT ON COLUMN metaschema_modules_public.secure_table_provision.owns IS 'Security concerns this row owns on the target table, as a jsonb array of "grants" and/or "policies". A listed concern is replaced: the target table''s existing grants (or its non-derived policies) are dropped before this row''s grants[] (or policies[]) are applied, so the row''s array is the table''s whole set — this is how a module-generated table''s default security is superseded rather than layered on. An unlisted concern composes, which is the default and the historical behavior. A concern may only be owned when this row supplies a non-empty array for it; owning a concern with nothing to install would leave the table with RLS enabled and no policy, and raises instead. Example: ["policies","grants"]. Defaults to ''[]'' (compose everything).'; + COMMENT ON COLUMN metaschema_modules_public.secure_table_provision.nodes IS 'Array of node objects to apply to the table. Each element is a jsonb object with a required "$type" key (one of: DataId, DataDirectOwner, DataEntityMembership, DataOwnershipInEntity, DataTimestamps, DataPeoplestamps, DataPublishable, DataSoftDelete, DataEmbedding, DataFullTextSearch, DataSlug, etc.) and an optional "data" key containing generator-specific configuration. Supports multiple nodes per row, matching the blueprint definition format. Example: [{"$type": "DataId"}, {"$type": "DataTimestamps"}, {"$type": "DataDirectOwner", "data": {"owner_field_name": "author_id"}}]. Defaults to ''[]'' (no node processing).'; COMMENT ON COLUMN metaschema_modules_public.secure_table_provision.use_rls IS 'If true and Row Level Security is not yet enabled on the target table, enable it. Automatically set to true by the trigger when policies[] is non-empty. Defaults to true.'; @@ -1842,6 +1848,7 @@ CREATE TABLE metaschema_modules_public.storage_module ( files_table_name text NOT NULL DEFAULT 'files', scope text NOT NULL, prefix text NOT NULL DEFAULT '', + key text NOT NULL DEFAULT 'default', policies jsonb NULL, provisions jsonb NULL, entity_table_id uuid NULL, @@ -1904,6 +1911,8 @@ CREATE TABLE metaschema_modules_public.storage_module ( ON DELETE CASCADE ); +CREATE UNIQUE INDEX storage_module_unique_key ON metaschema_modules_public.storage_module (database_id, scope, key); + CREATE UNIQUE INDEX storage_module_unique_scope ON metaschema_modules_public.storage_module (database_id, scope, prefix); CREATE INDEX storage_module_buckets_table_id_idx ON metaschema_modules_public.storage_module (buckets_table_id); @@ -2853,11 +2862,26 @@ CREATE TABLE metaschema_modules_public.billing_provider_module ( billing_refunds_table_name text NOT NULL DEFAULT '', billing_invoices_table_id uuid NOT NULL DEFAULT uuid_nil(), billing_invoices_table_name text NOT NULL DEFAULT '', + billing_disputes_table_id uuid NOT NULL DEFAULT uuid_nil(), + billing_disputes_table_name text NOT NULL DEFAULT '', process_billing_event_function text NOT NULL DEFAULT '', record_refund_function text NOT NULL DEFAULT '', upsert_invoice_function text NOT NULL DEFAULT '', list_pending_usage_sync_function text NOT NULL DEFAULT '', mark_usage_synced_function text NOT NULL DEFAULT '', + get_billing_customer_function text NOT NULL DEFAULT '', + upsert_billing_customer_function text NOT NULL DEFAULT '', + get_billing_product_function text NOT NULL DEFAULT '', + upsert_billing_product_function text NOT NULL DEFAULT '', + get_billing_price_function text NOT NULL DEFAULT '', + upsert_billing_price_function text NOT NULL DEFAULT '', + get_billing_subscription_function text NOT NULL DEFAULT '', + upsert_billing_subscription_function text NOT NULL DEFAULT '', + sweep_overdue_subscriptions_function text NOT NULL DEFAULT '', + get_active_plan_pricing_function text NOT NULL DEFAULT '', + get_fallback_free_plan_function text NOT NULL DEFAULT '', + record_dispute_function text NOT NULL DEFAULT '', + activate_plan_subscription_function text NOT NULL DEFAULT '', prefix text NULL, api_name text DEFAULT NULL, private_api_name text DEFAULT NULL, @@ -2901,6 +2925,10 @@ CREATE TABLE metaschema_modules_public.billing_provider_module ( FOREIGN KEY(billing_invoices_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, + CONSTRAINT billing_disputes_table_fkey + FOREIGN KEY(billing_disputes_table_id) + REFERENCES metaschema_public.table (id) + ON DELETE CASCADE, CONSTRAINT products_table_fkey FOREIGN KEY(products_table_id) REFERENCES metaschema_public.table (id) @@ -2931,6 +2959,8 @@ CREATE INDEX billing_provider_module_billing_refunds_table_id_idx ON metaschema_ CREATE INDEX billing_provider_module_billing_invoices_table_id_idx ON metaschema_modules_public.billing_provider_module (billing_invoices_table_id); +CREATE INDEX billing_provider_module_billing_disputes_table_id_idx ON metaschema_modules_public.billing_provider_module (billing_disputes_table_id); + CREATE INDEX billing_provider_module_prices_table_id_idx ON metaschema_modules_public.billing_provider_module (prices_table_id); CREATE INDEX billing_provider_module_products_table_id_idx ON metaschema_modules_public.billing_provider_module (products_table_id); @@ -2943,6 +2973,8 @@ CREATE INDEX billing_provider_module_schema_id_idx ON metaschema_modules_public. COMMENT ON COLUMN metaschema_modules_public.billing_provider_module.billing_customers_table_id IS '@module_table'; +COMMENT ON COLUMN metaschema_modules_public.billing_provider_module.billing_disputes_table_id IS '@module_table'; + COMMENT ON COLUMN metaschema_modules_public.billing_provider_module.billing_invoices_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.billing_provider_module.billing_prices_table_id IS '@module_table'; @@ -3408,6 +3440,7 @@ CREATE TABLE metaschema_modules_public.agent_module ( agent_table_id uuid DEFAULT NULL, persona_table_id uuid DEFAULT NULL, resource_table_id uuid DEFAULT NULL, + resource_repository_table_id uuid DEFAULT NULL, run_table_id uuid DEFAULT NULL, event_table_id uuid DEFAULT NULL, workspace_table_id uuid DEFAULT NULL, @@ -3419,12 +3452,14 @@ CREATE TABLE metaschema_modules_public.agent_module ( agent_table_name text NOT NULL DEFAULT 'agent', persona_table_name text NOT NULL DEFAULT 'agent_persona', resource_table_name text NOT NULL DEFAULT 'agent_resource', + resource_repository_table_name text NOT NULL DEFAULT 'agent_resource_repository', run_table_name text NOT NULL DEFAULT 'agent_run', event_table_name text NOT NULL DEFAULT 'agent_event', workspace_table_name text NOT NULL DEFAULT 'agent_run_workspace', has_plans boolean NOT NULL DEFAULT false, has_resources boolean NOT NULL DEFAULT false, has_agents boolean NOT NULL DEFAULT false, + has_repository_resources boolean NOT NULL DEFAULT false, has_runs boolean NOT NULL DEFAULT false, has_attachments boolean NOT NULL DEFAULT false, default_visibility text NOT NULL DEFAULT 'private' CONSTRAINT default_visibility_chk CHECK (default_visibility IN ('private', 'entity')), @@ -3481,6 +3516,10 @@ CREATE TABLE metaschema_modules_public.agent_module ( FOREIGN KEY(resource_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, + CONSTRAINT agent_module_resource_repository_table_fkey + FOREIGN KEY(resource_repository_table_id) + REFERENCES metaschema_public.table (id) + ON DELETE CASCADE, CONSTRAINT agent_module_run_table_fkey FOREIGN KEY(run_table_id) REFERENCES metaschema_public.table (id) @@ -3515,6 +3554,8 @@ CREATE INDEX agent_module_prompts_table_id_idx ON metaschema_modules_public.agen CREATE INDEX agent_module_resource_table_id_idx ON metaschema_modules_public.agent_module (resource_table_id); +CREATE INDEX agent_module_resource_repository_table_id_idx ON metaschema_modules_public.agent_module (resource_repository_table_id); + CREATE INDEX agent_module_run_table_id_idx ON metaschema_modules_public.agent_module (run_table_id); CREATE INDEX agent_module_event_table_id_idx ON metaschema_modules_public.agent_module (event_table_id); @@ -3541,6 +3582,8 @@ COMMENT ON COLUMN metaschema_modules_public.agent_module.prompts_table_id IS '@m COMMENT ON COLUMN metaschema_modules_public.agent_module.resource_table_id IS '@module_table'; +COMMENT ON COLUMN metaschema_modules_public.agent_module.resource_repository_table_id IS '@module_table'; + COMMENT ON COLUMN metaschema_modules_public.agent_module.task_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.agent_module.thread_table_id IS '@module_table'; @@ -3976,6 +4019,7 @@ CREATE TABLE metaschema_modules_public.function_module ( private_api_name text, scope text NOT NULL, prefix text NOT NULL DEFAULT '', + storage_key text NOT NULL DEFAULT 'default', entity_table_id uuid NULL, policies jsonb NULL, provisions jsonb NULL, @@ -4548,6 +4592,7 @@ CREATE TABLE metaschema_modules_public.resource_module ( resource_usage_summary_table_id uuid NOT NULL DEFAULT uuid_nil(), resource_installations_table_id uuid NOT NULL DEFAULT uuid_nil(), registry_bindings_table_id uuid NOT NULL DEFAULT uuid_nil(), + builder_bindings_table_id uuid NOT NULL DEFAULT uuid_nil(), resources_table_name text NOT NULL DEFAULT 'resources', resource_events_table_name text NOT NULL DEFAULT 'resource_events', resource_status_checks_table_name text NOT NULL DEFAULT 'resource_status_checks', @@ -4556,6 +4601,7 @@ CREATE TABLE metaschema_modules_public.resource_module ( resource_usage_summary_table_name text NOT NULL DEFAULT 'resource_usage_summary', resource_installations_table_name text NOT NULL DEFAULT 'resource_installations', registry_bindings_table_name text NOT NULL DEFAULT 'registry_bindings', + builder_bindings_table_name text NOT NULL DEFAULT 'builder_bindings', rollup_resource_usage_summary_function text NOT NULL DEFAULT '', resource_billing_rollup_function text NOT NULL DEFAULT '', resolved_requirements_view_name text, @@ -4615,6 +4661,10 @@ CREATE TABLE metaschema_modules_public.resource_module ( FOREIGN KEY(registry_bindings_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, + CONSTRAINT resource_module_builder_bindings_table_fkey + FOREIGN KEY(builder_bindings_table_id) + REFERENCES metaschema_public.table (id) + ON DELETE CASCADE, CONSTRAINT resource_module_merkle_store_module_fkey FOREIGN KEY(merkle_store_module_id) REFERENCES metaschema_modules_public.merkle_store_module (id) @@ -4639,6 +4689,8 @@ CREATE INDEX resource_module_resource_events_table_id_idx ON metaschema_modules_ CREATE INDEX resource_module_resource_installations_table_id_idx ON metaschema_modules_public.resource_module (resource_installations_table_id); +CREATE INDEX resource_module_builder_bindings_table_id_idx ON metaschema_modules_public.resource_module (builder_bindings_table_id); + CREATE INDEX resource_module_registry_bindings_table_id_idx ON metaschema_modules_public.resource_module (registry_bindings_table_id); CREATE INDEX resource_module_resources_table_id_idx ON metaschema_modules_public.resource_module (resources_table_id); @@ -4665,6 +4717,8 @@ COMMENT ON COLUMN metaschema_modules_public.resource_module.resource_installatio COMMENT ON COLUMN metaschema_modules_public.resource_module.registry_bindings_table_id IS '@module_table'; +COMMENT ON COLUMN metaschema_modules_public.resource_module.builder_bindings_table_id IS '@module_table'; + COMMENT ON COLUMN metaschema_modules_public.resource_module.resource_status_checks_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.resource_module.resource_usage_log_table_id IS '@module_table'; @@ -4838,6 +4892,7 @@ CREATE TABLE metaschema_modules_public.catalog_module ( schema_id uuid NOT NULL DEFAULT uuid_nil(), public_schema_name text, domains_table_id uuid NOT NULL DEFAULT uuid_nil(), + managed_domains_table_id uuid NOT NULL DEFAULT uuid_nil(), apis_table_id uuid NOT NULL DEFAULT uuid_nil(), sites_table_id uuid NOT NULL DEFAULT uuid_nil(), namespaces_table_id uuid NOT NULL DEFAULT uuid_nil(), @@ -4853,8 +4908,10 @@ CREATE TABLE metaschema_modules_public.catalog_module ( sites_error_pages_table_id uuid NOT NULL DEFAULT uuid_nil(), sites_app_links_table_id uuid NOT NULL DEFAULT uuid_nil(), sites_deep_links_table_id uuid NOT NULL DEFAULT uuid_nil(), + app_store_identities_table_id uuid NOT NULL DEFAULT uuid_nil(), redirects_table_id uuid NOT NULL DEFAULT uuid_nil(), domains_table_name text NOT NULL DEFAULT 'domains', + managed_domains_table_name text NOT NULL DEFAULT 'managed_domains', apis_table_name text NOT NULL DEFAULT 'apis', sites_table_name text NOT NULL DEFAULT 'sites', namespaces_table_name text NOT NULL DEFAULT 'namespaces', @@ -4870,6 +4927,7 @@ CREATE TABLE metaschema_modules_public.catalog_module ( sites_error_pages_table_name text NOT NULL DEFAULT 'sites_error_pages', sites_app_links_table_name text NOT NULL DEFAULT 'sites_app_links', sites_deep_links_table_name text NOT NULL DEFAULT 'sites_deep_links', + app_store_identities_table_name text NOT NULL DEFAULT 'app_store_identities', redirects_table_name text NOT NULL DEFAULT 'redirects', api_name text, private_api_name text, @@ -4890,6 +4948,10 @@ CREATE TABLE metaschema_modules_public.catalog_module ( FOREIGN KEY(domains_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, + CONSTRAINT catalog_module_managed_domains_table_fkey + FOREIGN KEY(managed_domains_table_id) + REFERENCES metaschema_public.table (id) + ON DELETE CASCADE, CONSTRAINT catalog_module_apis_table_fkey FOREIGN KEY(apis_table_id) REFERENCES metaschema_public.table (id) @@ -4950,6 +5012,10 @@ CREATE TABLE metaschema_modules_public.catalog_module ( FOREIGN KEY(sites_deep_links_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, + CONSTRAINT catalog_module_app_store_identities_table_fkey + FOREIGN KEY(app_store_identities_table_id) + REFERENCES metaschema_public.table (id) + ON DELETE CASCADE, CONSTRAINT catalog_module_redirects_table_fkey FOREIGN KEY(redirects_table_id) REFERENCES metaschema_public.table (id) @@ -4980,10 +5046,14 @@ CREATE INDEX catalog_module_sites_app_links_table_id_idx ON metaschema_modules_p CREATE INDEX catalog_module_sites_deep_links_table_id_idx ON metaschema_modules_public.catalog_module (sites_deep_links_table_id); +CREATE INDEX catalog_module_app_store_identities_table_id_idx ON metaschema_modules_public.catalog_module (app_store_identities_table_id); + CREATE INDEX catalog_module_redirects_table_id_idx ON metaschema_modules_public.catalog_module (redirects_table_id); CREATE INDEX catalog_module_domains_table_id_idx ON metaschema_modules_public.catalog_module (domains_table_id); +CREATE INDEX catalog_module_managed_domains_table_id_idx ON metaschema_modules_public.catalog_module (managed_domains_table_id); + CREATE INDEX catalog_module_entity_table_id_idx ON metaschema_modules_public.catalog_module (entity_table_id); CREATE INDEX catalog_module_functions_table_id_idx ON metaschema_modules_public.catalog_module (functions_table_id); @@ -5010,6 +5080,8 @@ COMMENT ON COLUMN metaschema_modules_public.catalog_module.buckets_table_id IS ' COMMENT ON COLUMN metaschema_modules_public.catalog_module.domains_table_id IS '@module_table'; +COMMENT ON COLUMN metaschema_modules_public.catalog_module.managed_domains_table_id IS '@module_table'; + COMMENT ON COLUMN metaschema_modules_public.catalog_module.functions_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.catalog_module.images_table_id IS '@module_table'; @@ -5028,6 +5100,8 @@ COMMENT ON COLUMN metaschema_modules_public.catalog_module.sites_app_links_table COMMENT ON COLUMN metaschema_modules_public.catalog_module.sites_deep_links_table_id IS '@module_table'; +COMMENT ON COLUMN metaschema_modules_public.catalog_module.app_store_identities_table_id IS '@module_table'; + COMMENT ON COLUMN metaschema_modules_public.catalog_module.sites_error_pages_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.catalog_module.sites_table_id IS '@module_table'; @@ -5217,6 +5291,7 @@ CREATE TABLE metaschema_modules_public.site_surface_module ( site_metadata_table_id uuid NOT NULL DEFAULT uuid_nil(), site_modules_table_id uuid NOT NULL DEFAULT uuid_nil(), site_themes_table_id uuid NOT NULL DEFAULT uuid_nil(), + site_releases_table_id uuid NOT NULL DEFAULT uuid_nil(), site_app_links_table_id uuid NOT NULL DEFAULT uuid_nil(), site_deep_links_table_id uuid NOT NULL DEFAULT uuid_nil(), site_web_config_table_id uuid NOT NULL DEFAULT uuid_nil(), @@ -5225,6 +5300,7 @@ CREATE TABLE metaschema_modules_public.site_surface_module ( site_metadata_table_name text NOT NULL DEFAULT 'site_metadata', site_modules_table_name text NOT NULL DEFAULT 'site_modules', site_themes_table_name text NOT NULL DEFAULT 'site_themes', + site_releases_table_name text NOT NULL DEFAULT 'site_releases', site_app_links_table_name text NOT NULL DEFAULT 'site_app_links', site_deep_links_table_name text NOT NULL DEFAULT 'site_deep_links', site_web_config_table_name text NOT NULL DEFAULT 'site_web_config', @@ -5233,6 +5309,7 @@ CREATE TABLE metaschema_modules_public.site_surface_module ( private_api_name text, scope text NOT NULL, prefix text NOT NULL DEFAULT '', + storage_key text NOT NULL DEFAULT 'default', entity_table_id uuid NULL, policies jsonb NULL, provisions jsonb NULL, @@ -5269,6 +5346,10 @@ CREATE TABLE metaschema_modules_public.site_surface_module ( FOREIGN KEY(site_themes_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, + CONSTRAINT site_module_site_releases_table_fkey + FOREIGN KEY(site_releases_table_id) + REFERENCES metaschema_public.table (id) + ON DELETE CASCADE, CONSTRAINT site_module_site_app_links_table_fkey FOREIGN KEY(site_app_links_table_id) REFERENCES metaschema_public.table (id) @@ -5301,6 +5382,8 @@ CREATE INDEX site_surface_module_site_modules_table_id_idx ON metaschema_modules CREATE INDEX site_surface_module_site_themes_table_id_idx ON metaschema_modules_public.site_surface_module (site_themes_table_id); +CREATE INDEX site_surface_module_site_releases_table_id_idx ON metaschema_modules_public.site_surface_module (site_releases_table_id); + CREATE INDEX site_surface_module_site_app_links_table_id_idx ON metaschema_modules_public.site_surface_module (site_app_links_table_id); CREATE INDEX site_surface_module_site_deep_links_table_id_idx ON metaschema_modules_public.site_surface_module (site_deep_links_table_id); @@ -5335,6 +5418,7 @@ CREATE TABLE metaschema_modules_public.route_module ( id uuid PRIMARY KEY DEFAULT uuidv7(), database_id uuid NOT NULL, entity_field text, + serving_site_field text, schema_id uuid NOT NULL DEFAULT uuid_nil(), private_schema_id uuid NOT NULL DEFAULT uuid_nil(), public_schema_name text, @@ -5356,6 +5440,7 @@ CREATE TABLE metaschema_modules_public.route_module ( private_api_name text, scope text NOT NULL, prefix text NOT NULL DEFAULT '', + storage_key text NOT NULL DEFAULT 'default', entity_table_id uuid NULL, policies jsonb NULL, provisions jsonb NULL, @@ -5443,6 +5528,7 @@ CREATE TABLE metaschema_modules_public.app_module ( app_components_table_id uuid NOT NULL DEFAULT uuid_nil(), apps_table_name text NOT NULL DEFAULT 'apps', app_components_table_name text NOT NULL DEFAULT 'app_components', + app_store_identities_table_name text NOT NULL DEFAULT 'app_store_identities', api_name text, private_api_name text, scope text NOT NULL, @@ -5587,6 +5673,11 @@ CREATE TABLE metaschema_modules_public.pages_module ( sites_table_id uuid NOT NULL DEFAULT uuid_nil(), pages_table_id uuid NOT NULL DEFAULT uuid_nil(), store_name_prefix text NOT NULL DEFAULT 'site:', + release_manifest_function_name text, + preview_commit_function_name text, + preview_set_function_name text, + preview_token_mint_function_name text, + preview_token_verifier_function_name text, api_name text, private_api_name text, entity_table_id uuid NULL, @@ -6162,6 +6253,7 @@ CREATE TABLE metaschema_modules_public.repository_module ( private_schema_name text, repositories_table_id uuid NOT NULL DEFAULT uuid_nil(), repository_events_table_id uuid NOT NULL DEFAULT uuid_nil(), + repository_required_checks_table_id uuid NOT NULL DEFAULT uuid_nil(), workflows_table_id uuid NOT NULL DEFAULT uuid_nil(), builds_table_id uuid NOT NULL DEFAULT uuid_nil(), build_steps_table_id uuid NOT NULL DEFAULT uuid_nil(), @@ -6172,6 +6264,7 @@ CREATE TABLE metaschema_modules_public.repository_module ( proposal_file_views_table_id uuid NOT NULL DEFAULT uuid_nil(), repositories_table_name text NOT NULL DEFAULT 'repositories', repository_events_table_name text NOT NULL DEFAULT 'repository_events', + repository_required_checks_table_name text NOT NULL DEFAULT 'repository_required_checks', workflows_table_name text NOT NULL DEFAULT 'repository_workflows', builds_table_name text NOT NULL DEFAULT 'builds', build_steps_table_name text NOT NULL DEFAULT 'build_steps', @@ -6211,6 +6304,10 @@ CREATE TABLE metaschema_modules_public.repository_module ( FOREIGN KEY(repository_events_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, + CONSTRAINT repository_module_required_checks_table_fkey + FOREIGN KEY(repository_required_checks_table_id) + REFERENCES metaschema_public.table (id) + ON DELETE CASCADE, CONSTRAINT repository_module_workflows_table_fkey FOREIGN KEY(workflows_table_id) REFERENCES metaschema_public.table (id) @@ -6257,6 +6354,8 @@ CREATE INDEX repository_module_repositories_table_id_idx ON metaschema_modules_p CREATE INDEX repository_module_repository_events_table_id_idx ON metaschema_modules_public.repository_module (repository_events_table_id); +CREATE INDEX repository_module_repository_required_checks_table_id_idx ON metaschema_modules_public.repository_module (repository_required_checks_table_id); + CREATE INDEX repository_module_workflows_table_id_idx ON metaschema_modules_public.repository_module (workflows_table_id); CREATE INDEX repository_module_builds_table_id_idx ON metaschema_modules_public.repository_module (builds_table_id); diff --git a/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/pages_module/table.sql b/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/pages_module/table.sql index f7a7568fa..9c44499af 100644 --- a/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/pages_module/table.sql +++ b/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/pages_module/table.sql @@ -3,7 +3,9 @@ BEGIN; SELECT id, database_id, public_schema_id, private_schema_id, merkle_store_module_id, - site_surface_module_id, sites_table_id, pages_table_id, store_name_prefix, scope, prefix + site_surface_module_id, sites_table_id, pages_table_id, store_name_prefix, scope, prefix, + preview_commit_function_name, preview_set_function_name, + preview_token_mint_function_name, preview_token_verifier_function_name FROM metaschema_modules_public.pages_module WHERE FALSE; diff --git a/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/resource_module/table.sql b/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/resource_module/table.sql index b4eac27a0..ed3658a97 100644 --- a/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/resource_module/table.sql +++ b/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/resource_module/table.sql @@ -4,6 +4,7 @@ BEGIN; SELECT id, database_id, schema_id, private_schema_id, resources_table_id, resource_events_table_id, + builder_bindings_table_id, builder_bindings_table_name, resources_table_name, resource_events_table_name, resolved_requirements_view_name, requirements_state_view_name, scope, prefix, entity_table_id, namespace_module_id, diff --git a/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/secure_table_provision/table.sql b/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/secure_table_provision/table.sql index 256018398..dbb589522 100644 --- a/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/secure_table_provision/table.sql +++ b/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/secure_table_provision/table.sql @@ -8,6 +8,9 @@ SELECT schema_id, table_id, table_name, + module, + owns, + nodes, use_rls, fields, grants, diff --git a/packages/metaschema-schema/__tests__/meta.test.ts b/packages/metaschema-schema/__tests__/meta.test.ts index 51614e20c..a2b356778 100644 --- a/packages/metaschema-schema/__tests__/meta.test.ts +++ b/packages/metaschema-schema/__tests__/meta.test.ts @@ -35,4 +35,194 @@ describe('metaschema_schema functionality', () => { expect(database.name).toBe('test-db'); expect(database.id).toBeDefined(); }); + + describe('trigger customer attachment shape', () => { + let database_id: string; + let table_id: string; + let function_id: string; + + beforeEach(async () => { + const owner_id = '07281002-1699-4762-57e3-ab1b92243120'; + ({ id: database_id } = await pg.one( + `INSERT INTO metaschema_public.database (owner_id, name) + VALUES ($1, $2) RETURNING id`, + [owner_id, 'trigger-db'] + )); + const { id: schema_id } = await pg.one( + `INSERT INTO metaschema_public.schema (database_id, name, schema_name) + VALUES ($1, $2, $3) RETURNING id`, + [database_id, 'app_public', 'trigger_test_app_public'] + ); + ({ id: table_id } = await pg.one( + `INSERT INTO metaschema_public.table (database_id, schema_id, name) + VALUES ($1, $2, $3) RETURNING id`, + [database_id, schema_id, 'orders'] + )); + ({ id: function_id } = await pg.one( + `INSERT INTO metaschema_public.function (database_id, schema_id, name, kind, returns, volatility, body_ast) + VALUES ($1, $2, $3, 'trigger', '{"type":{"name":"trigger"}}', 'VOLATILE', '{}') RETURNING id`, + [database_id, schema_id, 'orders_audit_tg'] + )); + }); + + const insertTrigger = (name: string, columns: Record) => { + const extra = Object.keys(columns); + const cols = ['database_id', 'table_id', 'name', ...extra]; + const params = [database_id, table_id, name, ...extra.map((k) => columns[k])]; + const placeholders = params.map((_, i) => `$${i + 1}`); + return pg.one( + `INSERT INTO metaschema_public.trigger (${cols.join(', ')}) + VALUES (${placeholders.join(', ')}) RETURNING *`, + params + ); + }; + + it('still accepts existing generated rows unchanged', async () => { + const row = await insertTrigger('generated_tg', { + event: 'INSERT', + function_name: 'app_hidden.some_generated_fn' + }); + expect(row.kind).toBe('reservation'); + expect(row.function_id).toBeNull(); + expect(row.timing).toBeNull(); + expect(row.events).toBeNull(); + expect(row.for_each_row).toBeNull(); + expect(row.when_ast).toBeNull(); + }); + + it('accepts a reservation whose events round-tripped as an empty array', async () => { + const row = await insertTrigger('seeded_tg', { + function_name: 'app_hidden.some_generated_fn', + events: [] + }); + expect(row.kind).toBe('reservation'); + expect(row.events).toEqual([]); + }); + + it('accepts the AFTER ... FOR EACH ROW customer shape', async () => { + const row = await insertTrigger('customer_tg', { + kind: 'attachment', + function_id, + timing: 'AFTER', + events: ['INSERT', 'UPDATE', 'DELETE'], + for_each_row: true + }); + expect(row.kind).toBe('attachment'); + expect(row.function_id).toBe(function_id); + expect(row.events).toEqual(['INSERT', 'UPDATE', 'DELETE']); + }); + + it('rejects an attachment kind without a function', async () => { + await expect( + insertTrigger('kindless_tg', { + kind: 'attachment', + timing: 'AFTER', + events: ['INSERT'], + for_each_row: true + }) + ).rejects.toThrow(/trigger_kind_matches_attachment/); + }); + + it('rejects a function on a reservation row', async () => { + await expect( + insertTrigger('reserved_with_fn_tg', { + function_id, + timing: 'AFTER', + events: ['INSERT'], + for_each_row: true + }) + ).rejects.toThrow(/trigger_kind_matches_attachment/); + }); + + it('rejects an attachment definition on a reservation row', async () => { + await expect( + insertTrigger('reserved_with_timing_tg', { + timing: 'AFTER', + events: ['INSERT'], + for_each_row: true + }) + ).rejects.toThrow(/trigger_reservation_has_no_definition/); + }); + + it('rejects an unknown kind', async () => { + await expect( + insertTrigger('bogus_kind_tg', { kind: 'physical' }) + ).rejects.toThrow(/trigger_kind_valid/); + }); + + it('refuses to delete a function that is still attached', async () => { + await insertTrigger('attached_tg', { + kind: 'attachment', + function_id, + timing: 'AFTER', + events: ['INSERT'], + for_each_row: true + }); + await expect( + pg.any(`DELETE FROM metaschema_public.function WHERE id = $1`, [ + function_id + ]) + ).rejects.toThrow(/function_fkey/); + }); + + it('rejects BEFORE when function_id is set', async () => { + await expect( + insertTrigger('before_tg', { + kind: 'attachment', + function_id, + timing: 'BEFORE', + events: ['INSERT'], + for_each_row: true + }) + ).rejects.toThrow(/trigger_customer_attachment_shape/); + }); + + it('rejects statement-level triggers when function_id is set', async () => { + await expect( + insertTrigger('stmt_tg', { + kind: 'attachment', + function_id, + timing: 'AFTER', + events: ['INSERT'], + for_each_row: false + }) + ).rejects.toThrow(/trigger_customer_attachment_shape/); + }); + + it('rejects TRUNCATE events when function_id is set', async () => { + await expect( + insertTrigger('truncate_tg', { + kind: 'attachment', + function_id, + timing: 'AFTER', + events: ['TRUNCATE'], + for_each_row: true + }) + ).rejects.toThrow(/trigger_customer_attachment_shape/); + }); + + it('rejects empty events when function_id is set', async () => { + await expect( + insertTrigger('empty_events_tg', { + kind: 'attachment', + function_id, + timing: 'AFTER', + events: [], + for_each_row: true + }) + ).rejects.toThrow(/trigger_customer_attachment_shape/); + }); + + it('rejects missing events when function_id is set', async () => { + await expect( + insertTrigger('null_events_tg', { + kind: 'attachment', + function_id, + timing: 'AFTER', + events: null, + for_each_row: true + }) + ).rejects.toThrow(/trigger_customer_attachment_shape/); + }); + }); }); diff --git a/packages/metaschema-schema/deploy/schemas/metaschema_public/tables/function/table.sql b/packages/metaschema-schema/deploy/schemas/metaschema_public/tables/function/table.sql index 8f5980c1d..4929ccc88 100644 --- a/packages/metaschema-schema/deploy/schemas/metaschema_public/tables/function/table.sql +++ b/packages/metaschema-schema/deploy/schemas/metaschema_public/tables/function/table.sql @@ -3,6 +3,7 @@ -- requires: schemas/metaschema_public/schema -- requires: schemas/metaschema_public/tables/database/table -- requires: schemas/metaschema_public/tables/schema/table +-- requires: schemas/metaschema_public/types/object_category BEGIN; @@ -13,7 +14,122 @@ CREATE TABLE metaschema_public.function ( name text NOT NULL, + -- What this row is: + -- 'reservation' — name-only. The function is emitted by a generator and + -- this row exists to hand out a stable, FK-able id and to + -- reserve (schema_id, name) against a customer-defined + -- function taking the same name. + -- 'sql' — this row owns the definition of a pure SQL function. + -- 'plpgsql' — this row owns the definition of a PL/pgSQL function. + -- 'trigger' — this row owns the definition of a PL/pgSQL trigger + -- function: no arguments, RETURNS trigger, never callable + -- through the API. It fires inside whatever transaction + -- writes the table it is attached to, so it is gated, + -- validated and emitted separately from a plain function. + -- For a definition row the kind is also the function's LANGUAGE ('trigger' + -- being PL/pgSQL), so the two can never disagree. + kind text NOT NULL DEFAULT 'reservation', + + -- Signature (definition rows only). + -- arguments: [{ "name": "user_id", "type": { "name": "uuid" }, "mode": "in", "default": ... }, …] + -- returns: { "type": { "name": "boolean" } } | { "setof": true, "type": … } | { "table": [ … ] } + arguments jsonb NOT NULL DEFAULT '[]', + returns jsonb, + + volatility text, + is_strict boolean NOT NULL DEFAULT false, + + -- SECURITY DEFINER is deliberately not expressible: a customer-owned + -- function runs as its invoker and stays subject to RLS. + security_invoker boolean NOT NULL DEFAULT true, + + -- Tier A authoring: a typed Function* node type plus its parameters, the + -- same shape metaschema_public.view uses for View* types. The body AST is + -- derived from these at generation time. + function_type text, + data jsonb DEFAULT '{}', + + -- Tier B authoring: the validated native AST. For 'sql' a statement AST, + -- for 'plpgsql' the complete PLpgSQL_function AST including its top-level + -- `datums` array — never a body fragment, because NEW/OLD field references + -- resolve by datum index. + body_ast jsonb, + + smart_tags jsonb, + + -- Whether this function is published to the generated API. -- + -- PostGraphile exposes every function it finds in a published schema, so a + -- customer function landing in app_public would become a query or mutation + -- field by existing. Publishing executable code is a separate decision from + -- authoring it, and the safe answer when nobody made that decision is no: + -- the default is false, and provisioning both denies every API behavior on + -- the emitted function and withholds EXECUTE until this is turned on. + api_exposed boolean NOT NULL DEFAULT false, + + category metaschema_public.object_category NOT NULL DEFAULT 'app', + + tags citext[] NOT NULL DEFAULT '{}', + + -- + CONSTRAINT function_kind_valid CHECK (kind IN ('reservation', 'sql', 'plpgsql', 'trigger')), + + CONSTRAINT function_volatility_valid CHECK ( + volatility IS NULL OR volatility IN ('IMMUTABLE', 'STABLE', 'VOLATILE') + ), + + CONSTRAINT function_security_invoker_only CHECK (security_invoker), + + -- A reservation is a name held for a generated function, whose own + -- generator decides its API surface; the flag would describe nothing. + CONSTRAINT function_reservation_not_api_exposed CHECK ( + kind <> 'reservation' OR NOT api_exposed + ), + + -- A reservation row carries no executable definition. + CONSTRAINT function_reservation_has_no_definition CHECK ( + kind <> 'reservation' + OR ( + arguments = '[]'::jsonb + AND returns IS NULL + AND volatility IS NULL + AND function_type IS NULL + AND body_ast IS NULL + ) + ), + + -- A trigger function takes no arguments and returns the trigger + -- pseudo-type: PostgreSQL calls it with the row in NEW/OLD rather than + -- through a signature, and any other shape cannot be attached at all. + CONSTRAINT function_trigger_signature CHECK ( + kind <> 'trigger' + OR ( + arguments = '[]'::jsonb + AND returns = '{"type": {"name": "trigger"}}'::jsonb + AND volatility = 'VOLATILE' + AND function_type IS NULL + AND body_ast IS NOT NULL + ) + ), + + -- A trigger function is reached by firing, not by calling: it needs no + -- EXECUTE grant, and exposing it would publish a function whose only + -- argument type cannot be spelled in a GraphQL field. + CONSTRAINT function_trigger_not_api_exposed CHECK ( + kind <> 'trigger' OR NOT api_exposed + ), + + -- A definition row carries a complete signature and at least one authoring + -- tier to generate the body from. + CONSTRAINT function_definition_complete CHECK ( + kind = 'reservation' + OR ( + returns IS NOT NULL + AND volatility IS NOT NULL + AND (function_type IS NOT NULL OR body_ast IS NOT NULL) + ) + ), + CONSTRAINT db_fkey FOREIGN KEY (database_id) REFERENCES metaschema_public.database (id) ON DELETE CASCADE, CONSTRAINT schema_fkey FOREIGN KEY (schema_id) REFERENCES metaschema_public.schema (id) ON DELETE CASCADE, @@ -21,5 +137,6 @@ CREATE TABLE metaschema_public.function ( ); CREATE INDEX function_database_id_idx ON metaschema_public.function ( database_id ); +CREATE INDEX function_kind_idx ON metaschema_public.function ( kind ); COMMIT; diff --git a/packages/metaschema-schema/deploy/schemas/metaschema_public/tables/identity_provider_registry/table.sql b/packages/metaschema-schema/deploy/schemas/metaschema_public/tables/identity_provider_registry/table.sql new file mode 100644 index 000000000..0406e53d9 --- /dev/null +++ b/packages/metaschema-schema/deploy/schemas/metaschema_public/tables/identity_provider_registry/table.sql @@ -0,0 +1,32 @@ +-- Deploy schemas/metaschema_public/tables/identity_provider_registry/table to pg + +-- requires: schemas/metaschema_public/schema + +BEGIN; + +-- Catalog of built-in identity providers. Each row is the protocol metadata +-- for one built-in IdP; provisioning reads this table and seeds a tenant +-- catalog row per provider (credentials NULL, enabled false) so nothing is +-- offered for sign-in until an admin configures it. Adding a provider or +-- correcting an endpoint is an INSERT/UPDATE here, not a code change. +CREATE TABLE metaschema_public.identity_provider_registry ( + slug text PRIMARY KEY, + -- 'oidc' rows carry issuer_url (endpoints resolve via discovery); + -- 'oauth2' rows carry the three explicit endpoint URLs. + kind text NOT NULL CHECK (kind IN ('oauth2', 'oidc')), + display_name text NOT NULL, + issuer_url text, + authorization_url text, + token_url text, + userinfo_url text, + scopes text[] NOT NULL DEFAULT '{}'::text[], + CHECK ( + (kind = 'oidc' AND issuer_url IS NOT NULL) + OR (kind = 'oauth2' + AND authorization_url IS NOT NULL + AND token_url IS NOT NULL + AND userinfo_url IS NOT NULL) + ) +); + +COMMIT; diff --git a/packages/metaschema-schema/deploy/schemas/metaschema_public/tables/trigger/table.sql b/packages/metaschema-schema/deploy/schemas/metaschema_public/tables/trigger/table.sql index 7cd4f7ab1..c82774fe0 100644 --- a/packages/metaschema-schema/deploy/schemas/metaschema_public/tables/trigger/table.sql +++ b/packages/metaschema-schema/deploy/schemas/metaschema_public/tables/trigger/table.sql @@ -2,6 +2,7 @@ -- requires: schemas/metaschema_public/schema -- requires: schemas/metaschema_public/tables/table/table +-- requires: schemas/metaschema_public/tables/function/table -- requires: schemas/metaschema_public/types/object_category BEGIN; @@ -17,6 +18,33 @@ CREATE TABLE metaschema_public.trigger ( event text, -- INSERT, UPDATE, DELETE, or TRUNCATE function_name text, + -- What this row is, mirroring metaschema_public.function.kind: + -- 'reservation' — name-only. The trigger itself is emitted by a generator, + -- or captured from the catalog by introspection, and this + -- row exists to hand out a stable, FK-able id and to + -- reserve (table_id, name) against a customer attachment + -- taking the same name. + -- 'attachment' — this row owns the definition of a customer trigger: which + -- customer function fires, on which events, under which + -- condition. The physical trigger is re-derived from the row. + kind text NOT NULL DEFAULT 'reservation', + + -- Customer trigger attachment. A row with function_id set attaches a + -- customer-defined trigger function (metaschema_public.function, + -- kind='trigger') to the target table. Generated/reservation rows leave all + -- of these NULL and keep their current lifecycle. + function_id uuid, + + -- Stored as readable enums, translated to the trigger type bitmask at + -- emission time. + timing text, + events text[], + for_each_row boolean, + + -- Optional WHEN condition. A condition is an AST, never a string or DSL; + -- it is validated and deparsed at emission time. + when_ast jsonb, + smart_tags jsonb, category metaschema_public.object_category NOT NULL DEFAULT 'app', @@ -28,11 +56,64 @@ CREATE TABLE metaschema_public.trigger ( CONSTRAINT db_fkey FOREIGN KEY (database_id) REFERENCES metaschema_public.database (id) ON DELETE CASCADE, CONSTRAINT table_fkey FOREIGN KEY (table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, + -- RESTRICT rather than CASCADE: dropping a trigger function that is still + -- attached is a mistake to report, not an attachment to delete silently. An + -- attachment is removed by deleting the trigger row, which is the only path + -- that also drops the physical trigger. + CONSTRAINT function_fkey FOREIGN KEY (function_id) REFERENCES metaschema_public.function (id) ON DELETE RESTRICT, + + CONSTRAINT trigger_kind_valid CHECK (kind IN ('reservation', 'attachment')), + + -- The kind and the relation are two spellings of the same fact, so neither + -- can be set without the other: a reservation never names a function, and an + -- attachment is nothing without one. + CONSTRAINT trigger_kind_matches_attachment CHECK ( + (kind = 'attachment') = (function_id IS NOT NULL) + ), + + -- A reservation carries no attachment definition. An empty events array is + -- accepted alongside NULL because that is how the seed exporter round-trips a + -- null array, and both say the same thing: no events. + CONSTRAINT trigger_reservation_has_no_definition CHECK ( + kind <> 'reservation' + OR ( + timing IS NULL + AND coalesce(cardinality(events), 0) = 0 + AND for_each_row IS NULL + AND when_ast IS NULL + ) + ), + + -- event and function_name are the generated-trigger spelling: a single event + -- and a physical function name, filled in by whichever generator emitted the + -- trigger. An attachment says the same things in function_id and events, and + -- resolves the physical name from the function row, so carrying both would be + -- two answers to one question. + CONSTRAINT trigger_attachment_has_no_legacy_definition CHECK ( + kind <> 'attachment' + OR (event IS NULL AND function_name IS NULL) + ), + + -- The customer attachment posture: AFTER, FOR EACH ROW, on a non-empty + -- subset of {INSERT, UPDATE, DELETE}. BEFORE (a body could rewrite NEW + -- before RLS/checks see it), TRUNCATE, statement-level, constraint/ + -- deferrable triggers and transition relations are not representable. + CONSTRAINT trigger_customer_attachment_shape CHECK ( + function_id IS NULL + OR ( + timing = 'AFTER' + AND for_each_row + AND events IS NOT NULL + AND cardinality(events) > 0 + AND events <@ ARRAY['INSERT', 'UPDATE', 'DELETE'] + ) + ), UNIQUE(table_id, name) ); CREATE INDEX trigger_database_id_idx ON metaschema_public.trigger ( database_id ); +CREATE INDEX trigger_function_id_idx ON metaschema_public.trigger ( function_id ); COMMIT; diff --git a/packages/metaschema-schema/pgpm.plan b/packages/metaschema-schema/pgpm.plan index cf67a3381..5314cf8c5 100644 --- a/packages/metaschema-schema/pgpm.plan +++ b/packages/metaschema-schema/pgpm.plan @@ -23,7 +23,6 @@ schemas/metaschema_public/tables/schema_grant/table [schemas/metaschema_public/s schemas/metaschema_public/tables/table_grant/table [schemas/metaschema_public/schema schemas/metaschema_public/tables/table/table] 2017-08-11T08:11:51Z skitch # add schemas/metaschema_public/tables/table_grant/table schemas/metaschema_public/tables/table/indexes/databases_table_unique_name_idx [schemas/metaschema_public/schema schemas/metaschema_private/schema schemas/metaschema_public/tables/table/table] 2017-08-11T08:11:51Z skitch # add schemas/metaschema_public/tables/table/indexes/databases_table_unique_name_idx schemas/metaschema_public/tables/trigger_function/table [schemas/metaschema_public/schema schemas/metaschema_public/tables/database/table] 2017-08-11T08:11:51Z skitch # add schemas/metaschema_public/tables/trigger_function/table -schemas/metaschema_public/tables/trigger/table [schemas/metaschema_public/schema schemas/metaschema_public/tables/table/table] 2017-08-11T08:11:51Z skitch # add schemas/metaschema_public/tables/trigger/table schemas/metaschema_public/tables/unique_constraint/table [schemas/metaschema_public/schema schemas/metaschema_public/tables/database/table schemas/metaschema_public/tables/table/table] 2017-08-11T08:11:51Z skitch # add schemas/metaschema_public/tables/unique_constraint/table schemas/metaschema_public/tables/view/table [schemas/metaschema_public/schema schemas/metaschema_public/tables/schema/table schemas/metaschema_public/tables/table/table schemas/metaschema_public/tables/database/table schemas/metaschema_public/types/object_category] 2026-01-23T00:00:00Z devin # add schemas/metaschema_public/tables/view/table schemas/metaschema_public/tables/view_table/table [schemas/metaschema_public/schema schemas/metaschema_public/tables/view/table schemas/metaschema_public/tables/table/table] 2026-01-23T00:00:00Z devin # add schemas/metaschema_public/tables/view_table/table @@ -35,6 +34,7 @@ schemas/metaschema_public/tables/embedding_chunks/table [schemas/metaschema_publ schemas/metaschema_public/tables/spatial_relation/table [schemas/metaschema_public/schema schemas/metaschema_public/tables/database/table schemas/metaschema_public/tables/table/table schemas/metaschema_public/tables/field/table schemas/metaschema_public/types/object_category] 2026-04-17T00:00:00Z devin # add schemas/metaschema_public/tables/spatial_relation/table schemas/metaschema_public/tables/node_type_registry/table [schemas/metaschema_public/schema] 2026-04-30T00:00:00Z Constructive # add schemas/metaschema_public/tables/node_type_registry/table schemas/metaschema_public/tables/function/table [schemas/metaschema_public/schema schemas/metaschema_public/tables/database/table schemas/metaschema_public/tables/schema/table] 2026-05-09T00:00:00Z devin # add metaschema_public.function table for tracking generated SQL functions +schemas/metaschema_public/tables/trigger/table [schemas/metaschema_public/schema schemas/metaschema_public/tables/table/table schemas/metaschema_public/tables/function/table] 2017-08-11T08:11:51Z skitch # add schemas/metaschema_public/tables/trigger/table schemas/metaschema_public/tables/partition/table [schemas/metaschema_public/schema schemas/metaschema_public/tables/database/table schemas/metaschema_public/tables/table/table schemas/metaschema_public/tables/field/table] 2026-05-26T00:00:00Z Constructive # add metaschema_public.partition table for pg_partman lifecycle config schemas/metaschema_public/tables/composite_type/table [schemas/metaschema_public/schema schemas/metaschema_public/tables/database/table schemas/metaschema_public/tables/schema/table schemas/metaschema_public/types/object_category] 2026-05-29T00:00:00Z devin # add metaschema_public.composite_type table for generated composite types schemas/metaschema_public/tables/domain_type/table [schemas/metaschema_public/schema schemas/metaschema_public/tables/database/table schemas/metaschema_public/tables/schema/table schemas/metaschema_public/types/object_category] 2026-07-22T00:00:00Z devin # add metaschema_public.domain_type table for declarative CREATE DOMAIN support @@ -46,3 +46,4 @@ schemas/metaschema_public/tables/field_behavior/table [schemas/metaschema_public schemas/metaschema_public/tables/view_behavior/table [schemas/metaschema_public/schema schemas/metaschema_public/tables/database/table schemas/metaschema_public/tables/view/table] 2026-08-01T00:00:00Z devin # add metaschema_public.view_behavior: one row per PostGraphile v5 behavior fragment schemas/metaschema_public/tables/foreign_key_constraint_behavior/table [schemas/metaschema_public/schema schemas/metaschema_public/tables/database/table schemas/metaschema_public/tables/foreign_key_constraint/table] 2026-08-01T00:00:00Z devin # add metaschema_public.foreign_key_constraint_behavior: one row per PostGraphile v5 behavior fragment schemas/metaschema_public/tables/unique_constraint_behavior/table [schemas/metaschema_public/schema schemas/metaschema_public/tables/database/table schemas/metaschema_public/tables/unique_constraint/table] 2026-08-01T00:00:00Z devin # add metaschema_public.unique_constraint_behavior: one row per PostGraphile v5 behavior fragment +schemas/metaschema_public/tables/identity_provider_registry/table [schemas/metaschema_public/schema] 2026-08-21T00:00:00Z devin # built-in identity provider catalog (google, github, apple, facebook, microsoft) — protocol metadata read at provision time to seed tenant rows diff --git a/packages/metaschema-schema/revert/schemas/metaschema_public/tables/identity_provider_registry/table.sql b/packages/metaschema-schema/revert/schemas/metaschema_public/tables/identity_provider_registry/table.sql new file mode 100644 index 000000000..e921d9c63 --- /dev/null +++ b/packages/metaschema-schema/revert/schemas/metaschema_public/tables/identity_provider_registry/table.sql @@ -0,0 +1,7 @@ +-- Revert schemas/metaschema_public/tables/identity_provider_registry/table from pg + +BEGIN; + +DROP TABLE IF EXISTS metaschema_public.identity_provider_registry; + +COMMIT; diff --git a/packages/metaschema-schema/sql/metaschema-schema--0.43.2.bundle.tar.gz b/packages/metaschema-schema/sql/metaschema-schema--0.43.2.bundle.tar.gz index 85c36980e..614c2dd54 100644 Binary files a/packages/metaschema-schema/sql/metaschema-schema--0.43.2.bundle.tar.gz and b/packages/metaschema-schema/sql/metaschema-schema--0.43.2.bundle.tar.gz differ diff --git a/packages/metaschema-schema/sql/metaschema-schema--0.43.2.sql b/packages/metaschema-schema/sql/metaschema-schema--0.43.2.sql index f366efeff..4780454af 100644 --- a/packages/metaschema-schema/sql/metaschema-schema--0.43.2.sql +++ b/packages/metaschema-schema/sql/metaschema-schema--0.43.2.sql @@ -798,31 +798,6 @@ CREATE TABLE metaschema_public.trigger_function ( UNIQUE (database_id, name) ); -CREATE TABLE metaschema_public.trigger ( - id uuid PRIMARY KEY DEFAULT uuidv7(), - database_id uuid NOT NULL DEFAULT uuid_nil(), - table_id uuid NOT NULL, - name text NOT NULL, - event text, - function_name text, - smart_tags jsonb, - category metaschema_public.object_category NOT NULL DEFAULT 'app', - tags citext[] NOT NULL DEFAULT '{}', - created_at timestamptz DEFAULT now(), - updated_at timestamptz DEFAULT now(), - CONSTRAINT db_fkey - FOREIGN KEY(database_id) - REFERENCES metaschema_public.database (id) - ON DELETE CASCADE, - CONSTRAINT table_fkey - FOREIGN KEY(table_id) - REFERENCES metaschema_public.table (id) - ON DELETE CASCADE, - UNIQUE (table_id, name) -); - -CREATE INDEX trigger_database_id_idx ON metaschema_public.trigger (database_id); - CREATE TABLE metaschema_public.unique_constraint ( id uuid PRIMARY KEY DEFAULT uuidv7(), database_id uuid NOT NULL DEFAULT uuid_nil(), @@ -1149,6 +1124,64 @@ CREATE TABLE metaschema_public.function ( database_id uuid NOT NULL, schema_id uuid NOT NULL, name text NOT NULL, + kind text NOT NULL DEFAULT 'reservation', + arguments jsonb NOT NULL DEFAULT '[]', + returns jsonb, + volatility text, + is_strict boolean NOT NULL DEFAULT false, + security_invoker boolean NOT NULL DEFAULT true, + function_type text, + data jsonb DEFAULT '{}', + body_ast jsonb, + smart_tags jsonb, + api_exposed boolean NOT NULL DEFAULT false, + category metaschema_public.object_category NOT NULL DEFAULT 'app', + tags citext[] NOT NULL DEFAULT '{}', + CONSTRAINT function_kind_valid + CHECK (kind IN ('reservation', 'sql', 'plpgsql', 'trigger')), + CONSTRAINT function_volatility_valid + CHECK ( + volatility IS NULL + OR volatility IN ('IMMUTABLE', 'STABLE', 'VOLATILE') + ), + CONSTRAINT function_security_invoker_only + CHECK (security_invoker), + CONSTRAINT function_reservation_not_api_exposed + CHECK ( + kind <> 'reservation' + OR NOT (api_exposed) + ), + CONSTRAINT function_reservation_has_no_definition + CHECK ( + kind <> 'reservation' + OR (arguments = '[]'::jsonb + AND returns IS NULL + AND volatility IS NULL + AND function_type IS NULL + AND body_ast IS NULL) + ), + CONSTRAINT function_trigger_signature + CHECK ( + kind <> 'trigger' + OR (arguments = '[]'::jsonb + AND returns = '{"type": {"name": "trigger"}}'::jsonb + AND volatility = 'VOLATILE' + AND function_type IS NULL + AND body_ast IS NOT NULL) + ), + CONSTRAINT function_trigger_not_api_exposed + CHECK ( + kind <> 'trigger' + OR NOT (api_exposed) + ), + CONSTRAINT function_definition_complete + CHECK ( + kind = 'reservation' + OR (returns IS NOT NULL + AND volatility IS NOT NULL + AND (function_type IS NOT NULL + OR body_ast IS NOT NULL)) + ), CONSTRAINT db_fkey FOREIGN KEY(database_id) REFERENCES metaschema_public.database (id) @@ -1162,6 +1195,72 @@ CREATE TABLE metaschema_public.function ( CREATE INDEX function_database_id_idx ON metaschema_public.function (database_id); +CREATE INDEX function_kind_idx ON metaschema_public.function (kind); + +CREATE TABLE metaschema_public.trigger ( + id uuid PRIMARY KEY DEFAULT uuidv7(), + database_id uuid NOT NULL DEFAULT uuid_nil(), + table_id uuid NOT NULL, + name text NOT NULL, + event text, + function_name text, + kind text NOT NULL DEFAULT 'reservation', + function_id uuid, + timing text, + events text[], + for_each_row boolean, + when_ast jsonb, + smart_tags jsonb, + category metaschema_public.object_category NOT NULL DEFAULT 'app', + tags citext[] NOT NULL DEFAULT '{}', + created_at timestamptz DEFAULT now(), + updated_at timestamptz DEFAULT now(), + CONSTRAINT db_fkey + FOREIGN KEY(database_id) + REFERENCES metaschema_public.database (id) + ON DELETE CASCADE, + CONSTRAINT table_fkey + FOREIGN KEY(table_id) + REFERENCES metaschema_public.table (id) + ON DELETE CASCADE, + CONSTRAINT function_fkey + FOREIGN KEY(function_id) + REFERENCES metaschema_public.function (id) + ON DELETE RESTRICT, + CONSTRAINT trigger_kind_valid + CHECK (kind IN ('reservation', 'attachment')), + CONSTRAINT trigger_kind_matches_attachment + CHECK ((kind = 'attachment') = (function_id IS NOT NULL)), + CONSTRAINT trigger_reservation_has_no_definition + CHECK ( + kind <> 'reservation' + OR (timing IS NULL + AND (COALESCE(cardinality(events), 0)) = 0 + AND for_each_row IS NULL + AND when_ast IS NULL) + ), + CONSTRAINT trigger_attachment_has_no_legacy_definition + CHECK ( + kind <> 'attachment' + OR (event IS NULL + AND function_name IS NULL) + ), + CONSTRAINT trigger_customer_attachment_shape + CHECK ( + function_id IS NULL + OR (timing = 'AFTER' + AND for_each_row + AND events IS NOT NULL + AND cardinality(events) > 0 + AND events <@ ARRAY['INSERT', 'UPDATE', 'DELETE']) + ), + UNIQUE (table_id, name) +); + +CREATE INDEX trigger_database_id_idx ON metaschema_public.trigger (database_id); + +CREATE INDEX trigger_function_id_idx ON metaschema_public.trigger (function_id); + CREATE TABLE metaschema_public.partition ( id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), database_id uuid NOT NULL, @@ -1447,4 +1546,23 @@ CREATE TABLE metaschema_public.unique_constraint_behavior ( UNIQUE (unique_constraint_id, scope) ); -CREATE INDEX unique_constraint_behavior_database_id_idx ON metaschema_public.unique_constraint_behavior (database_id); \ No newline at end of file +CREATE INDEX unique_constraint_behavior_database_id_idx ON metaschema_public.unique_constraint_behavior (database_id); + +CREATE TABLE metaschema_public.identity_provider_registry ( + slug text PRIMARY KEY, + kind text NOT NULL CHECK (kind IN ('oauth2', 'oidc')), + display_name text NOT NULL, + issuer_url text, + authorization_url text, + token_url text, + userinfo_url text, + scopes text[] NOT NULL DEFAULT CAST('{}' AS text[]), + CHECK ( + (kind = 'oidc' + AND issuer_url IS NOT NULL) + OR (kind = 'oauth2' + AND authorization_url IS NOT NULL + AND token_url IS NOT NULL + AND userinfo_url IS NOT NULL) + ) +); \ No newline at end of file diff --git a/packages/metaschema-schema/verify/schemas/metaschema_public/tables/function/table.sql b/packages/metaschema-schema/verify/schemas/metaschema_public/tables/function/table.sql index ae36d2213..2fbe82dbb 100644 --- a/packages/metaschema-schema/verify/schemas/metaschema_public/tables/function/table.sql +++ b/packages/metaschema-schema/verify/schemas/metaschema_public/tables/function/table.sql @@ -2,6 +2,10 @@ BEGIN; -SELECT assert_table('metaschema_public.function'::regclass); +SELECT id, database_id, schema_id, name, kind, arguments, returns, volatility, + is_strict, security_invoker, function_type, data, body_ast, smart_tags, + api_exposed, category, tags +FROM metaschema_public.function +WHERE FALSE; ROLLBACK; diff --git a/packages/metaschema-schema/verify/schemas/metaschema_public/tables/identity_provider_registry/table.sql b/packages/metaschema-schema/verify/schemas/metaschema_public/tables/identity_provider_registry/table.sql new file mode 100644 index 000000000..3bf2024f9 --- /dev/null +++ b/packages/metaschema-schema/verify/schemas/metaschema_public/tables/identity_provider_registry/table.sql @@ -0,0 +1,9 @@ +-- Verify schemas/metaschema_public/tables/identity_provider_registry/table on pg + +BEGIN; + +SELECT slug, kind, display_name, issuer_url, authorization_url, token_url, userinfo_url, scopes +FROM metaschema_public.identity_provider_registry +WHERE FALSE; + +ROLLBACK;