Skip to content
Open
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
2 changes: 1 addition & 1 deletion agentic/agentic-server/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "agentic-server",
"version": "0.8.0",
"description": "Standalone Express LLM service agent threads, chat streaming, billing metering, and inference logging via express-context",
"description": "Standalone Express LLM service - agent threads, chat streaming, billing metering, and inference logging via express-context",
"main": "index.js",
"module": "esm/index.js",
"types": "index.d.ts",
Expand Down
2 changes: 1 addition & 1 deletion graphile/graphile-bucket-provisioner-plugin/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "graphile-bucket-provisioner-plugin",
"version": "0.13.0",
"description": "Bucket provisioning plugin for PostGraphile v5 auto-provisions S3 buckets on bucket table mutations",
"description": "Bucket provisioning plugin for PostGraphile v5 - auto-provisions S3 buckets on bucket table mutations",
"author": "Constructive <developers@constructive.io>",
"homepage": "https://github.com/constructive-io/constructive",
"license": "MIT",
Expand Down
3 changes: 2 additions & 1 deletion graphile/graphile-i18n/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "graphile-i18n",
"version": "1.4.1",
"description": "PostGraphile v5 i18n plugin language-aware fields from @i18n translation tables with Accept-Language negotiation and fallback chains",
"description": "PostGraphile v5 i18n plugin \u2014 language-aware fields from @i18n translation tables with Accept-Language negotiation and fallback chains",
"author": "Constructive <developers@constructive.io>",
"homepage": "https://github.com/constructive-io/constructive",
"license": "MIT",
Expand Down Expand Up @@ -29,6 +29,7 @@
"url": "https://github.com/constructive-io/constructive/issues"
},
"dependencies": {
"@pgsql/quotes": "^17.1.0",
"accept-language-parser": "^1.5.0"
},
"peerDependencies": {
Expand Down
35 changes: 24 additions & 11 deletions graphile/graphile-i18n/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import 'graphile-build';
import 'graphile-build-pg';
import { TYPES } from '@dataplan/pg';
import type { PgCodecWithAttributes } from '@dataplan/pg';
import { QuoteUtils } from '@pgsql/quotes';
import { context as grafastContext, lambda, object } from 'grafast';
import type { GraphileConfig } from 'graphile-config';

Expand Down Expand Up @@ -74,7 +75,7 @@ export function createI18nPlugin(options: I18nPluginOptions = {}): GraphileConfi

// Closure-scoped state shared between init and field hooks
let i18nRegistry: Record<string, I18nTableInfo> = {};
const localeTypeCache: Record<string, any> = {};
let localeTypeCache: Record<string, any> = {};

return {
name: 'I18nPlugin',
Expand All @@ -85,6 +86,7 @@ export function createI18nPlugin(options: I18nPluginOptions = {}): GraphileConfi
init: {
callback(_, build) {
i18nRegistry = {};
localeTypeCache = {};

for (const [, codec] of Object.entries(build.input.pgRegistry.pgCodecs)) {
const c = codec as PgCodecWithAttributes;
Expand Down Expand Up @@ -234,18 +236,27 @@ export function createI18nPlugin(options: I18nPluginOptions = {}): GraphileConfi

const { schemaName, baseTable, translationTable, fkColumn, pkColumn, pkType, fields: i18nFields } = info;

// Identifier quoting via @pgsql/quotes (quote_ident semantics: quoted
// only when lexically required — uppercase, special chars, reserved
// keywords). Hashed multi-tenant schema names contain '-' so they are
// always emitted double-quoted, which schema-identifier rewriting at
// the pool seam relies on.
const qi = (name: string): string => QuoteUtils.quoteIdentifier(name);
const coalescedCols = Object.values(i18nFields)
.map(f => `coalesce(v."${f.column}", b."${f.column}") as "${f.column}"`)
.map(f => `coalesce(v.${qi(f.column)}, b.${qi(f.column)}) as ${qi(f.column)}`)
.join(', ');

const baseTableRef = QuoteUtils.quoteQualifiedIdentifier(schemaName, baseTable);
const translationTableRef = QuoteUtils.quoteQualifiedIdentifier(schemaName, translationTable);

// Build the SQL query template
const sqlQuery = `SELECT v."${langCodeColumn}" AS "lang_code", ${coalescedCols}
FROM "${schemaName}"."${baseTable}" b
LEFT JOIN "${schemaName}"."${translationTable}" v
ON v."${fkColumn}" = b."${pkColumn}"
AND array_position($2::text[], v."${langCodeColumn}") IS NOT NULL
WHERE b."${pkColumn}" = $1::${pkType}
ORDER BY array_position($2::text[], v."${langCodeColumn}") ASC NULLS LAST
const sqlQuery = `SELECT v.${qi(langCodeColumn)} AS "lang_code", ${coalescedCols}
FROM ${baseTableRef} b
LEFT JOIN ${translationTableRef} v
ON v.${qi(fkColumn)} = b.${qi(pkColumn)}
AND array_position($2::text[], v.${qi(langCodeColumn)}) IS NOT NULL
WHERE b.${qi(pkColumn)} = $1::${pkType}
ORDER BY array_position($2::text[], v.${qi(langCodeColumn)}) ASC NULLS LAST
LIMIT 1`;

// Build column names list for mapping base values
Expand All @@ -265,18 +276,20 @@ export function createI18nPlugin(options: I18nPluginOptions = {}): GraphileConfi
$baseCols[column] = $parent.get(column);
}
const $withPgClient = (grafastContext() as any).get('withPgClient');
const $pgSettings = (grafastContext() as any).get('pgSettings');
const $langCodes = (grafastContext() as any).get('langCodes');

// Combine all inputs into a single step
const $input = object({
id: $id,
withPgClient: $withPgClient,
pgSettings: $pgSettings,
langCodes: $langCodes,
...$baseCols,
});

return lambda($input, async (input: any) => {
const { id, withPgClient, langCodes: ctxLangCodes, ...baseCols } = input;
const { id, withPgClient, pgSettings, langCodes: ctxLangCodes, ...baseCols } = input;
const langs: string[] = ctxLangCodes ?? defaultLanguages;

if (!withPgClient || !id) {
Expand All @@ -287,7 +300,7 @@ export function createI18nPlugin(options: I18nPluginOptions = {}): GraphileConfi
return result;
}

const row = await withPgClient(null, async (client: any) => {
const row = await withPgClient(pgSettings, async (client: any) => {
const { rows } = await client.query(sqlQuery, [id, langs]);
return rows[0] ?? null;
});
Expand Down
3 changes: 2 additions & 1 deletion graphile/graphile-llm/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "graphile-llm",
"version": "0.16.1",
"description": "LLM integration plugin for PostGraphile v5 server-side text-to-vector embedding and text companion fields for pgvector columns",
"description": "LLM integration plugin for PostGraphile v5 \u2014 server-side text-to-vector embedding and text companion fields for pgvector columns",
"author": "Constructive <developers@constructive.io>",
"homepage": "https://github.com/constructive-io/constructive",
"license": "MIT",
Expand Down Expand Up @@ -32,6 +32,7 @@
"@agentic-kit/ollama": "workspace:*",
"@constructive-io/express-context": "workspace:^",
"@constructive-io/llm-env": "workspace:^",
"@pgsql/quotes": "^17.1.0",
"graphile-cache": "workspace:^"
},
"peerDependencies": {
Expand Down
148 changes: 148 additions & 0 deletions graphile/graphile-llm/src/__tests__/agent-discovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* Agent discovery tenant-isolation tests (pure, no DB required).
*
* Verifies the cross-tenant bleed fix: discovery is filtered by the requesting
* tenant's database id (resolved from jwt.claims.database_id in pgSettings and
* passed as $1), and the per-database cache is keyed by that id — never by
* dbname alone — so a shared/pooled instance cannot return another tenant's
* agent config.
*/

import { clearAgentDiscoveryCache, getAgentDiscovery } from '../plugins/agent-discovery-plugin';

// ─── Fake pool ───────────────────────────────────────────────────────────────

interface QueryCall {
sql: string;
values: unknown[] | undefined;
}

/**
* A fake pg Pool whose `query` scopes rows by the $1 database id — mirroring how
* the real (shared) control-plane metaschema tables behave once the WHERE
* clause is applied. Records every call for assertions.
*/
function makeFakePool(rowsByDatabaseId: Record<string, any>) {
const calls: QueryCall[] = [];
const pool = {
calls,
query: async (sql: string, values?: unknown[]) => {
calls.push({ sql, values });
const id = values?.[0] as string | null;
const row = id != null ? rowsByDatabaseId[id] : undefined;
return { rows: row ? [row] : [] };
}
};
return pool;
}

const TENANT_1 = '11111111-1111-1111-1111-111111111111';
const TENANT_2 = '22222222-2222-2222-2222-222222222222';

const ROWS = {
[TENANT_1]: {
schema_name: 'tenant1_agent',
thread_table_name: 'agent_thread',
message_table_name: 'agent_message',
task_table_name: 'agent_task'
},
[TENANT_2]: {
schema_name: 'tenant2_agent',
thread_table_name: 't2_thread',
message_table_name: 't2_message',
task_table_name: 't2_task'
}
};

// ─── Tests ───────────────────────────────────────────────────────────────────

describe('getAgentDiscovery — tenant isolation', () => {
beforeEach(() => {
clearAgentDiscoveryCache();
});

it('filters by the tenant database id from pgSettings, passed as $1', async () => {
const pool = makeFakePool(ROWS);

const result = await getAgentDiscovery(pool as any, 'shared-db', {
'jwt.claims.database_id': TENANT_1
});

expect(pool.calls).toHaveLength(1);
expect(pool.calls[0].sql).toContain('WHERE s.database_id = $1');
expect(pool.calls[0].values).toEqual([TENANT_1]);

expect(result).not.toBeNull();
expect(result!.thread).toEqual({ schemaName: 'tenant1_agent', tableName: 'agent_thread' });
expect(result!.message).toEqual({ schemaName: 'tenant1_agent', tableName: 'agent_message' });
expect(result!.task).toEqual({ schemaName: 'tenant1_agent', tableName: 'agent_task' });
});

it('does NOT bleed across tenants sharing one instance/dbname', async () => {
const pool = makeFakePool(ROWS);

const t1 = await getAgentDiscovery(pool as any, 'shared-db', {
'jwt.claims.database_id': TENANT_1
});
const t2 = await getAgentDiscovery(pool as any, 'shared-db', {
'jwt.claims.database_id': TENANT_2
});

// Each tenant gets its own config despite the same dbname.
expect(t1!.thread!.schemaName).toBe('tenant1_agent');
expect(t2!.thread!.schemaName).toBe('tenant2_agent');

// Two distinct database ids ⇒ two separate cache entries ⇒ two queries.
expect(pool.calls.map((c) => c.values)).toEqual([[TENANT_1], [TENANT_2]]);
});

it('caches by database id (same id ⇒ a single query)', async () => {
const pool = makeFakePool(ROWS);

const a = await getAgentDiscovery(pool as any, 'shared-db', {
'jwt.claims.database_id': TENANT_1
});
const b = await getAgentDiscovery(pool as any, 'shared-db', {
'jwt.claims.database_id': TENANT_1
});

expect(pool.calls).toHaveLength(1);
expect(a).toEqual(b);
});

it('fails closed when database id is absent — passes null as $1, keys under <dbname>:nodb', async () => {
const pool = makeFakePool(ROWS);

// No pgSettings at all.
const r1 = await getAgentDiscovery(pool as any, 'shared-db');
// Empty pgSettings (no jwt.claims.database_id).
const r2 = await getAgentDiscovery(pool as any, 'shared-db', {});

expect(r1).toBeNull();
expect(r2).toBeNull();

// First call queries with null $1; second is served from the ':nodb' cache.
expect(pool.calls).toHaveLength(1);
expect(pool.calls[0].values).toEqual([null]);

// A different dbname without an id uses a different ':nodb' key ⇒ new query.
await getAgentDiscovery(pool as any, 'other-db');
expect(pool.calls).toHaveLength(2);
});

it('caches null (unprovisioned tenant) without re-querying', async () => {
// Pool returns no rows for this (valid) tenant id ⇒ discovery is null.
const pool = makeFakePool({});

const first = await getAgentDiscovery(pool as any, 'shared-db', {
'jwt.claims.database_id': TENANT_1
});
const second = await getAgentDiscovery(pool as any, 'shared-db', {
'jwt.claims.database_id': TENANT_1
});

expect(first).toBeNull();
expect(second).toBeNull();
expect(pool.calls).toHaveLength(1);
});
});
Loading
Loading