Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 58 additions & 58 deletions bin/knowledge.js

Large diffs are not rendered by default.

85 changes: 84 additions & 1 deletion dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18270,7 +18270,90 @@ var PG_MIGRATIONS = [
`CREATE TRIGGER trg_knowledge_items_00_guarded_authority
BEFORE INSERT OR UPDATE OR DELETE ON knowledge_items
FOR EACH ROW EXECUTE FUNCTION knowledge_guarded_item_authority()`,
`ALTER TABLE knowledge_items ENABLE ALWAYS TRIGGER trg_knowledge_items_00_guarded_authority`
`ALTER TABLE knowledge_items ENABLE ALWAYS TRIGGER trg_knowledge_items_00_guarded_authority`,
`CREATE OR REPLACE FUNCTION knowledge_guarded_item_authority()
RETURNS TRIGGER AS $knowledge_guarded_item_authority$
DECLARE
claim_key TEXT;
claim_matches BOOLEAN;
BEGIN
IF TG_OP = 'DELETE' THEN
IF OLD.authority_classification IS NULL THEN
RETURN OLD;
END IF;
RAISE EXCEPTION 'guarded knowledge items cannot be deleted outside a declared FCAME-1 action'
USING ERRCODE = 'restrict_violation';
END IF;

IF TG_OP = 'INSERT' AND NEW.authority_classification IS NULL THEN
RETURN NEW;
END IF;

IF TG_OP = 'UPDATE'
AND OLD.authority_classification IS NULL
AND NEW.authority_classification IS NULL THEN
RETURN NEW;
END IF;

IF NEW.authority_classification IS NULL OR NEW.authority_id IS NULL
OR NEW.tenant_id IS NULL OR NEW.scope IS NULL OR NEW.parent_id IS NULL THEN
RAISE EXCEPTION 'guarded knowledge item binding must be complete'
USING ERRCODE = 'check_violation';
END IF;

IF TG_OP = 'UPDATE' AND (
OLD.id IS DISTINCT FROM NEW.id
OR OLD.authority_classification IS DISTINCT FROM NEW.authority_classification
OR OLD.authority_id IS DISTINCT FROM NEW.authority_id
OR OLD.tenant_id IS DISTINCT FROM NEW.tenant_id
OR OLD.scope IS DISTINCT FROM NEW.scope
OR OLD.parent_id IS DISTINCT FROM NEW.parent_id
) THEN
RAISE EXCEPTION 'guarded knowledge item identity and binding are immutable'
USING ERRCODE = 'restrict_violation';
END IF;

claim_key := NULLIF(
current_setting('hasna.knowledge_guarded_deterministic_key', true),
''
);
IF claim_key IS NULL THEN
RAISE EXCEPTION 'guarded knowledge item mutation requires an FCAME-1 operation claim'
USING ERRCODE = 'insufficient_privilege';
END IF;

SELECT EXISTS (
SELECT 1
FROM knowledge_guarded_write_claims AS claim
WHERE claim.deterministic_key = claim_key
AND claim.receipt_id IS NULL
AND claim.target_id = NEW.id
AND claim.authority_classification = NEW.authority_classification
AND claim.authority_id = NEW.authority_id
AND claim.tenant_id = NEW.tenant_id::text
AND claim.scope = NEW.scope
AND claim.parent_id = NEW.parent_id
AND (
(
TG_OP = 'INSERT'
AND claim.verb = 'create'
AND claim.precondition_kind = 'absent'
)
OR (
TG_OP = 'UPDATE'
AND claim.verb = 'update'
AND claim.precondition_kind = 'version'
AND claim.expected_version = OLD.version
)
)
) INTO claim_matches;
IF NOT claim_matches THEN
RAISE EXCEPTION 'guarded knowledge item mutation does not match its live FCAME-1 operation claim'
USING ERRCODE = 'insufficient_privilege';
END IF;
RETURN NEW;
END
$knowledge_guarded_item_authority$ LANGUAGE plpgsql`
];
// src/serve.ts
import { readFileSync as readFileSync5 } from "fs";
Expand Down
85 changes: 84 additions & 1 deletion dist/storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -3400,7 +3400,90 @@ var PG_MIGRATIONS = [
`CREATE TRIGGER trg_knowledge_items_00_guarded_authority
BEFORE INSERT OR UPDATE OR DELETE ON knowledge_items
FOR EACH ROW EXECUTE FUNCTION knowledge_guarded_item_authority()`,
`ALTER TABLE knowledge_items ENABLE ALWAYS TRIGGER trg_knowledge_items_00_guarded_authority`
`ALTER TABLE knowledge_items ENABLE ALWAYS TRIGGER trg_knowledge_items_00_guarded_authority`,
`CREATE OR REPLACE FUNCTION knowledge_guarded_item_authority()
RETURNS TRIGGER AS $knowledge_guarded_item_authority$
DECLARE
claim_key TEXT;
claim_matches BOOLEAN;
BEGIN
IF TG_OP = 'DELETE' THEN
IF OLD.authority_classification IS NULL THEN
RETURN OLD;
END IF;
RAISE EXCEPTION 'guarded knowledge items cannot be deleted outside a declared FCAME-1 action'
USING ERRCODE = 'restrict_violation';
END IF;

IF TG_OP = 'INSERT' AND NEW.authority_classification IS NULL THEN
RETURN NEW;
END IF;

IF TG_OP = 'UPDATE'
AND OLD.authority_classification IS NULL
AND NEW.authority_classification IS NULL THEN
RETURN NEW;
END IF;

IF NEW.authority_classification IS NULL OR NEW.authority_id IS NULL
OR NEW.tenant_id IS NULL OR NEW.scope IS NULL OR NEW.parent_id IS NULL THEN
RAISE EXCEPTION 'guarded knowledge item binding must be complete'
USING ERRCODE = 'check_violation';
END IF;

IF TG_OP = 'UPDATE' AND (
OLD.id IS DISTINCT FROM NEW.id
OR OLD.authority_classification IS DISTINCT FROM NEW.authority_classification
OR OLD.authority_id IS DISTINCT FROM NEW.authority_id
OR OLD.tenant_id IS DISTINCT FROM NEW.tenant_id
OR OLD.scope IS DISTINCT FROM NEW.scope
OR OLD.parent_id IS DISTINCT FROM NEW.parent_id
) THEN
RAISE EXCEPTION 'guarded knowledge item identity and binding are immutable'
USING ERRCODE = 'restrict_violation';
END IF;

claim_key := NULLIF(
current_setting('hasna.knowledge_guarded_deterministic_key', true),
''
);
IF claim_key IS NULL THEN
RAISE EXCEPTION 'guarded knowledge item mutation requires an FCAME-1 operation claim'
USING ERRCODE = 'insufficient_privilege';
END IF;

SELECT EXISTS (
SELECT 1
FROM knowledge_guarded_write_claims AS claim
WHERE claim.deterministic_key = claim_key
AND claim.receipt_id IS NULL
AND claim.target_id = NEW.id
AND claim.authority_classification = NEW.authority_classification
AND claim.authority_id = NEW.authority_id
AND claim.tenant_id = NEW.tenant_id::text
AND claim.scope = NEW.scope
AND claim.parent_id = NEW.parent_id
AND (
(
TG_OP = 'INSERT'
AND claim.verb = 'create'
AND claim.precondition_kind = 'absent'
)
OR (
TG_OP = 'UPDATE'
AND claim.verb = 'update'
AND claim.precondition_kind = 'version'
AND claim.expected_version = OLD.version
)
)
) INTO claim_matches;
IF NOT claim_matches THEN
RAISE EXCEPTION 'guarded knowledge item mutation does not match its live FCAME-1 operation claim'
USING ERRCODE = 'insufficient_privilege';
END IF;
RETURN NEW;
END
$knowledge_guarded_item_authority$ LANGUAGE plpgsql`
];
export {
wrapExecutor,
Expand Down
89 changes: 89 additions & 0 deletions src/db/pg-migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -977,4 +977,93 @@ export const PG_MIGRATIONS: string[] = [
BEFORE INSERT OR UPDATE OR DELETE ON knowledge_items
FOR EACH ROW EXECUTE FUNCTION knowledge_guarded_item_authority()`,
`ALTER TABLE knowledge_items ENABLE ALWAYS TRIGGER trg_knowledge_items_00_guarded_authority`,

// Hosted iapp-knowledge tenancy migrations add knowledge_items.tenant_id as
// UUID while FCAME-1 guarded claims retain TEXT tenant ids. Preserve the
// historical trigger migration above for checksum-ledger compatibility and
// append this replacement function so both fresh installs and already-ledgered
// databases get the portable comparison.
`CREATE OR REPLACE FUNCTION knowledge_guarded_item_authority()
RETURNS TRIGGER AS $knowledge_guarded_item_authority$
DECLARE
claim_key TEXT;
claim_matches BOOLEAN;
BEGIN
IF TG_OP = 'DELETE' THEN
IF OLD.authority_classification IS NULL THEN
RETURN OLD;
END IF;
RAISE EXCEPTION 'guarded knowledge items cannot be deleted outside a declared FCAME-1 action'
USING ERRCODE = 'restrict_violation';
END IF;

IF TG_OP = 'INSERT' AND NEW.authority_classification IS NULL THEN
RETURN NEW;
END IF;

IF TG_OP = 'UPDATE'
AND OLD.authority_classification IS NULL
AND NEW.authority_classification IS NULL THEN
RETURN NEW;
END IF;

IF NEW.authority_classification IS NULL OR NEW.authority_id IS NULL
OR NEW.tenant_id IS NULL OR NEW.scope IS NULL OR NEW.parent_id IS NULL THEN
RAISE EXCEPTION 'guarded knowledge item binding must be complete'
USING ERRCODE = 'check_violation';
END IF;

IF TG_OP = 'UPDATE' AND (
OLD.id IS DISTINCT FROM NEW.id
OR OLD.authority_classification IS DISTINCT FROM NEW.authority_classification
OR OLD.authority_id IS DISTINCT FROM NEW.authority_id
OR OLD.tenant_id IS DISTINCT FROM NEW.tenant_id
OR OLD.scope IS DISTINCT FROM NEW.scope
OR OLD.parent_id IS DISTINCT FROM NEW.parent_id
) THEN
RAISE EXCEPTION 'guarded knowledge item identity and binding are immutable'
USING ERRCODE = 'restrict_violation';
END IF;

claim_key := NULLIF(
current_setting('hasna.knowledge_guarded_deterministic_key', true),
''
);
IF claim_key IS NULL THEN
RAISE EXCEPTION 'guarded knowledge item mutation requires an FCAME-1 operation claim'
USING ERRCODE = 'insufficient_privilege';
END IF;

SELECT EXISTS (
SELECT 1
FROM knowledge_guarded_write_claims AS claim
WHERE claim.deterministic_key = claim_key
AND claim.receipt_id IS NULL
AND claim.target_id = NEW.id
AND claim.authority_classification = NEW.authority_classification
AND claim.authority_id = NEW.authority_id
AND claim.tenant_id = NEW.tenant_id::text
AND claim.scope = NEW.scope
AND claim.parent_id = NEW.parent_id
AND (
(
TG_OP = 'INSERT'
AND claim.verb = 'create'
AND claim.precondition_kind = 'absent'
)
OR (
TG_OP = 'UPDATE'
AND claim.verb = 'update'
AND claim.precondition_kind = 'version'
AND claim.expected_version = OLD.version
)
)
) INTO claim_matches;
IF NOT claim_matches THEN
RAISE EXCEPTION 'guarded knowledge item mutation does not match its live FCAME-1 operation claim'
USING ERRCODE = 'insufficient_privilege';
END IF;
RETURN NEW;
END
$knowledge_guarded_item_authority$ LANGUAGE plpgsql`,
];
61 changes: 57 additions & 4 deletions tests/fixtures/pglite-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@
import { PGlite } from '@electric-sql/pglite';
import { apiKeyMigrations } from '@hasna/contracts/auth';
import { PG_MIGRATIONS } from '../../src/db/pg-migrations';
import type { PoolQueryClient } from '../../src/generated/storage-kit/index.js';
import {
MigrationLedger,
defineMigration,
type PoolQueryClient,
} from '../../src/generated/storage-kit/index.js';

/**
* Wrap a PGlite instance in the `PoolQueryClient` vocabulary the serve layer
Expand Down Expand Up @@ -96,9 +100,58 @@ export async function applyKnowledgePgMigrations(db: PGlite): Promise<void> {
}
}

function knowledgeLedgerMigrations(pgMigrations: readonly string[] = PG_MIGRATIONS) {
return [
...pgMigrations.map((sql, index) =>
defineMigration(`knowledge_pg_${String(index + 1).padStart(3, '0')}`, sql),
),
];
}

export async function applyKnowledgePgMigrationsThroughLedger(
client: PoolQueryClient,
pgMigrations: readonly string[] = PG_MIGRATIONS,
) {
const ledger = new MigrationLedger(client, knowledgeLedgerMigrations(pgMigrations));
return ledger.migrate();
}

async function createHostedUuidTenantKnowledgeItemsSchema(db: PGlite): Promise<void> {
await db.exec(`
CREATE TABLE knowledge_items (
id TEXT PRIMARY KEY,
short_id TEXT,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
url TEXT,
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
archived BOOLEAN NOT NULL DEFAULT FALSE,
created_at TEXT NOT NULL DEFAULT NOW()::text,
updated_at TEXT NOT NULL DEFAULT NOW()::text,
tenant_id UUID
)
`);
}

/** A migrated, empty in-process Postgres plus its `PoolQueryClient`. */
export async function createMigratedPglite(): Promise<{ db: PGlite; client: PoolQueryClient }> {
export async function createMigratedPglite(options: {
knowledgeItemsTenantIdType?: 'text' | 'uuid';
migrationMode?: 'direct' | 'existing-ledger-upgrade';
} = {}): Promise<{ db: PGlite; client: PoolQueryClient }> {
const db = new PGlite();
await applyKnowledgePgMigrations(db);
return { db, client: pgliteClient(db) };
if (options.knowledgeItemsTenantIdType === 'uuid') {
await createHostedUuidTenantKnowledgeItemsSchema(db);
}
const client = pgliteClient(db);
if (options.migrationMode === 'existing-ledger-upgrade') {
await applyKnowledgePgMigrationsThroughLedger(client, PG_MIGRATIONS.slice(0, -1));
await applyKnowledgePgMigrationsThroughLedger(client);
for (const migration of apiKeyMigrations()) {
await db.exec(migration.sql);
}
} else {
await applyKnowledgePgMigrations(db);
}
return { db, client };
}
Loading
Loading