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
27 changes: 27 additions & 0 deletions packages/server/src/db/migrations/014_organizations_not_null.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- NOT NULL backstop for the organization tenant FKs. Migration 013 added
-- organization_id as nullable so the backfill and the app writers could be rolled out
-- incrementally; now that every writer (admin routes, create-admin, create-project) sets
-- it, we close the gap at the DB level so a tenant-less project or admin can never exist.

-- Catch any stragglers first: rows an older image may have created after 013 but before
-- the org-aware writers shipped. Bucket them into a 'Default' org (create it if needed)
-- so the constraint can be applied cleanly. In an in-order deploy there are none.
DO $$
DECLARE
default_org UUID;
BEGIN
IF EXISTS (SELECT 1 FROM admin_users WHERE organization_id IS NULL)
OR EXISTS (SELECT 1 FROM projects WHERE organization_id IS NULL) THEN
SELECT id INTO default_org FROM organizations WHERE name = 'Default' LIMIT 1;
IF default_org IS NULL THEN
INSERT INTO organizations (name, slug) VALUES ('Default', 'default')
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
RETURNING id INTO default_org;
END IF;
UPDATE admin_users SET organization_id = default_org WHERE organization_id IS NULL;
UPDATE projects SET organization_id = default_org WHERE organization_id IS NULL;
END IF;
END $$;

ALTER TABLE admin_users ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE projects ALTER COLUMN organization_id SET NOT NULL;
16 changes: 16 additions & 0 deletions packages/server/src/lib/organizations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { db } from '../db/client.js';

// Find an organization by name, or create it. Used by the bootstrap CLIs (create-admin,
// create-project) to attach the first admin/project to a tenant: a fresh self-host install
// has no org until the first bootstrap, so these scripts provision one. Multi-tenant hosts
// pass a distinct name per customer. Idempotent on name (re-running returns the same org).
export async function findOrCreateOrgByName(name: string): Promise<string> {
const existing = await db<{ id: string }[]>`
SELECT id FROM organizations WHERE name = ${name} LIMIT 1
`;
if (existing[0]) return existing[0].id;
const [org] = await db<{ id: string }[]>`
INSERT INTO organizations (name) VALUES (${name}) RETURNING id
`;
return org!.id;
}
14 changes: 10 additions & 4 deletions packages/server/src/pipeline/retention.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ import { migrate } from '../db/migrate.js';
import { db } from '../db/client.js';
import { sweepRetention } from './retention.js';
import { hashApiKey } from '../middleware/api-key.js';
import { createTestOrg, deleteTestOrg } from '../test-support/org.js';

// Gated on DATABASE_URL like the other integration suites (runs in CI, skips locally
// without a DB). See events.integration.test.ts for the rationale.
const hasDb = Boolean(process.env['DATABASE_URL']);
const API_KEY = 'retention-test-key';
const ORG = 'Retention IT Org';

let projectId: string; // retention_days = 30
let orgId: string;

async function seedIdentity(project: string, ageDays: number): Promise<string> {
const id = `ret-${ageDays}d-${crypto.randomUUID()}`;
Expand All @@ -24,16 +27,19 @@ beforeAll(async () => {
if (!hasDb) return;
await migrate();
await db`DELETE FROM projects WHERE api_key_hash IN (${hashApiKey(API_KEY)}, ${hashApiKey(`${API_KEY}-keep`)})`;
await deleteTestOrg(ORG);
orgId = await createTestOrg(ORG);
const [proj] = await db<{ id: string }[]>`
INSERT INTO projects (api_key_hash, name, retention_days)
VALUES (${hashApiKey(API_KEY)}, 'Retention Test', 30) RETURNING id
INSERT INTO projects (api_key_hash, name, retention_days, organization_id)
VALUES (${hashApiKey(API_KEY)}, 'Retention Test', 30, ${orgId}) RETURNING id
`;
projectId = proj!.id;
});

afterAll(async () => {
if (!hasDb) return;
await db`DELETE FROM projects WHERE api_key_hash IN (${hashApiKey(API_KEY)}, ${hashApiKey(`${API_KEY}-keep`)})`;
await deleteTestOrg(ORG);
await db.end();
});

Expand All @@ -55,8 +61,8 @@ describe.skipIf(!hasDb)('sweepRetention', () => {

it('skips projects with null retention_days (keep forever)', async () => {
const [keepProj] = await db<{ id: string }[]>`
INSERT INTO projects (api_key_hash, name)
VALUES (${hashApiKey(`${API_KEY}-keep`)}, 'Keep Forever') RETURNING id
INSERT INTO projects (api_key_hash, name, organization_id)
VALUES (${hashApiKey(`${API_KEY}-keep`)}, 'Keep Forever', ${orgId}) RETURNING id
`;
const ancient = await seedIdentity(keepProj!.id, 999);

Expand Down
7 changes: 6 additions & 1 deletion packages/server/src/risk/assess.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { migrate } from '../db/migrate.js';
import { db } from '../db/client.js';
import { hashApiKey } from '../middleware/api-key.js';
import { assessRisk } from './assess.js';
import { createTestOrg, deleteTestOrg } from '../test-support/org.js';

// Regression coverage for the risk_assessments.flags encoding bug: the insert used
// `${JSON.stringify(flags)}::jsonb`, which postgres.js double-encodes into a JSON
Expand All @@ -11,6 +12,7 @@ import { assessRisk } from './assess.js';
// ARRAY. Gated on DATABASE_URL.
const hasDb = Boolean(process.env['DATABASE_URL']);
const API_KEY = 'assess-integration-key';
const ORG = 'Assess IT Org';
const ID = 'assessit-identity-1';

let projectId: string;
Expand All @@ -20,8 +22,10 @@ beforeAll(async () => {
if (!hasDb) return;
await migrate();
await db`DELETE FROM projects WHERE api_key_hash = ${hashApiKey(API_KEY)}`;
await deleteTestOrg(ORG);
const org = await createTestOrg(ORG);
const [proj] = await db<{ id: string }[]>`
INSERT INTO projects (api_key_hash, name) VALUES (${hashApiKey(API_KEY)}, 'Assess Integration') RETURNING id
INSERT INTO projects (api_key_hash, name, organization_id) VALUES (${hashApiKey(API_KEY)}, 'Assess Integration', ${org}) RETURNING id
`;
projectId = proj!.id;
await db`
Expand All @@ -39,6 +43,7 @@ beforeAll(async () => {
afterAll(async () => {
if (!hasDb) return;
await db`DELETE FROM projects WHERE api_key_hash = ${hashApiKey(API_KEY)}`;
await deleteTestOrg(ORG);
await db.end();
});

Expand Down
7 changes: 6 additions & 1 deletion packages/server/src/routes/events.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { resolveSnapshot } from '../pipeline/resolve.js';
import { createQueueConnection, INGEST_QUEUE_NAME } from '../queue/ingest.js';
import type { IngestJobData } from '../queue/ingest.js';
import { hashApiKey } from '../middleware/api-key.js';
import { createTestOrg, deleteTestOrg } from '../test-support/org.js';

// Integration tests hit a real Postgres + Redis. They run in CI (which provides
// DATABASE_URL/REDIS_URL via service containers) and locally when those env vars
Expand All @@ -17,6 +18,7 @@ import { hashApiKey } from '../middleware/api-key.js';
const hasDb = Boolean(process.env['DATABASE_URL']);

const API_KEY = 'integration-test-key';
const ORG = 'Events IT Org';

// A rich, stable signal set: identical re-submissions resolve to the same identity
// with high confidence (canvas/audio/fonts/hardware dominate the weighting).
Expand Down Expand Up @@ -80,15 +82,18 @@ beforeAll(async () => {
await migrate();
await redis.flushdb();
await db`DELETE FROM projects WHERE api_key_hash = ${hashApiKey(API_KEY)}`; // cascades to identities/snapshots/links
await deleteTestOrg(ORG);
const org = await createTestOrg(ORG);
const [proj] = await db<{ id: string }[]>`
INSERT INTO projects (api_key_hash, name) VALUES (${hashApiKey(API_KEY)}, 'Integration Test') RETURNING id
INSERT INTO projects (api_key_hash, name, organization_id) VALUES (${hashApiKey(API_KEY)}, 'Integration Test', ${org}) RETURNING id
`;
projectId = proj!.id;
});

afterAll(async () => {
if (!hasDb) return;
await db`DELETE FROM projects WHERE api_key_hash = ${hashApiKey(API_KEY)}`;
await deleteTestOrg(ORG);
await redis.quit();
await db.end();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { db } from '../db/client.js';
import { redis } from '../db/redis.js';
import { resolveSnapshot } from '../pipeline/resolve.js';
import { hashApiKey } from '../middleware/api-key.js';
import { createTestOrg, deleteTestOrg } from '../test-support/org.js';

// Integration coverage for the *resolution decisions* — new-vs-returning boundary,
// ambiguous match, and cluster linking — that the happy-path suite doesn't reach.
Expand All @@ -14,6 +15,7 @@ import { hashApiKey } from '../middleware/api-key.js';
// skips locally without a DB).
const hasDb = Boolean(process.env['DATABASE_URL']);
const API_KEY = 'integration-resolution-key';
const ORG = 'Resolution IT Org';

// Base device: 4 stable (weight 0.9) + 5 moderate (0.55) signals.
const C = {
Expand Down Expand Up @@ -89,15 +91,18 @@ beforeAll(async () => {
await migrate();
await redis.flushdb();
await db`DELETE FROM projects WHERE api_key_hash = ${hashApiKey(API_KEY)}`;
await deleteTestOrg(ORG);
const org = await createTestOrg(ORG);
const [proj] = await db<{ id: string }[]>`
INSERT INTO projects (api_key_hash, name) VALUES (${hashApiKey(API_KEY)}, 'Resolution Integration') RETURNING id
INSERT INTO projects (api_key_hash, name, organization_id) VALUES (${hashApiKey(API_KEY)}, 'Resolution Integration', ${org}) RETURNING id
`;
projectId = proj!.id;
});

afterAll(async () => {
if (!hasDb) return;
await db`DELETE FROM projects WHERE api_key_hash = ${hashApiKey(API_KEY)}`;
await deleteTestOrg(ORG);
await redis.quit();
await db.end();
});
Expand Down
7 changes: 6 additions & 1 deletion packages/server/src/routes/queries.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import { migrate } from '../db/migrate.js';
import { db } from '../db/client.js';
import { redis } from '../db/redis.js';
import { hashApiKey } from '../middleware/api-key.js';
import { createTestOrg, deleteTestOrg } from '../test-support/org.js';

// Integration coverage for the read/query API (the GET routes + /v1/resolve), which
// the OpenAPI spec documents and the SDK/Observatory consume. Seeds a small graph and
// asserts status + response shape. Gated on DATABASE_URL like the other suites.
const hasDb = Boolean(process.env['DATABASE_URL']);
const API_KEY = 'queries-integration-key';
const ORG = 'Queries IT Org';

const app = createApp();
let projectId: string;
Expand All @@ -28,8 +30,10 @@ beforeAll(async () => {
await migrate();
await redis.flushdb();
await db`DELETE FROM projects WHERE api_key_hash = ${hashApiKey(API_KEY)}`;
await deleteTestOrg(ORG);
const org = await createTestOrg(ORG);
const [proj] = await db<{ id: string }[]>`
INSERT INTO projects (api_key_hash, name) VALUES (${hashApiKey(API_KEY)}, 'Queries Integration') RETURNING id
INSERT INTO projects (api_key_hash, name, organization_id) VALUES (${hashApiKey(API_KEY)}, 'Queries Integration', ${org}) RETURNING id
`;
projectId = proj!.id;

Expand Down Expand Up @@ -82,6 +86,7 @@ beforeAll(async () => {
afterAll(async () => {
if (!hasDb) return;
await db`DELETE FROM projects WHERE api_key_hash = ${hashApiKey(API_KEY)}`;
await deleteTestOrg(ORG);
await redis.quit();
await db.end();
});
Expand Down
22 changes: 14 additions & 8 deletions packages/server/src/scripts/create-admin.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,38 @@
import { db } from '../db/client.js';
import { hashPassword } from '../admin/password.js';
import { findOrCreateOrgByName } from '../lib/organizations.js';

// Bootstrap an admin user. This is the install owner: CLI-created admins are always
// 'owner' (full access — manages projects and, going forward, other admins). Member
// 'owner' (full access — manages projects and other admins WITHIN their org). Member
// accounts are provisioned from the Observatory, not here. Password is hashed with
// bcrypt before it touches the DB.
//
// tsx src/scripts/create-admin.ts <email> <password> (dev)
// docker compose exec scent-server node dist/scripts/create-admin.js <email> <pw>
// tsx src/scripts/create-admin.ts <email> <password> [orgName] (dev)
// docker compose exec scent-server node dist/scripts/create-admin.js <email> <pw> [org]
//
// Re-running for an existing email resets the password but leaves the role untouched.
// The optional org name (default 'Default') attaches the admin to a tenant, creating it
// if needed — this is how a fresh install gets its first org + owner, and how an operator
// adds a second tenant on a hosted box. Re-running for an existing email resets the
// password but leaves the role and org untouched.
async function main(): Promise<void> {
const email = process.argv[2]?.trim().toLowerCase();
const password = process.argv[3];
const orgName = process.argv[4]?.trim() || 'Default';
if (!email || !password) {
console.error('Usage: create-admin <email> <password>');
console.error('Usage: create-admin <email> <password> [orgName]');
process.exit(1);
}

const passwordHash = await hashPassword(password);
const organizationId = await findOrCreateOrgByName(orgName);
const [user] = await db<{ id: string }[]>`
INSERT INTO admin_users (email, password_hash, role)
VALUES (${email}, ${passwordHash}, 'owner')
INSERT INTO admin_users (email, password_hash, role, organization_id)
VALUES (${email}, ${passwordHash}, 'owner', ${organizationId})
ON CONFLICT (email) DO UPDATE SET password_hash = EXCLUDED.password_hash
RETURNING id
`;

console.error(`Admin user ready: ${email} (id: ${user?.id})`);
console.error(`Admin user ready: ${email} (id: ${user?.id}, org: ${orgName})`);
await db.end();
}

Expand Down
18 changes: 12 additions & 6 deletions packages/server/src/scripts/create-project.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,36 @@
import { db } from '../db/client.js';
import { mintApiKey } from '../lib/api-key.js';
import { findOrCreateOrgByName } from '../lib/organizations.js';

// Create a project and mint its API key. Only the key's hash is stored; the
// plaintext is printed once here and cannot be recovered later.
//
// tsx src/scripts/create-project.ts "Project Name" (dev)
// node dist/scripts/create-project.js "Project Name" (in the image)
// tsx src/scripts/create-project.ts "Project Name" [orgName] (dev)
// node dist/scripts/create-project.js "Project Name" [org] (in the image)
// docker compose exec scent-server node dist/scripts/create-project.js "Prod"
//
// The optional org name (default 'Default') attaches the project to a tenant, creating
// it if needed — every project belongs to an organization (migration 013).
async function main(): Promise<void> {
const name = process.argv[2];
const orgName = process.argv[3]?.trim() || 'Default';
if (!name) {
console.error('Usage: create-project "<project name>"');
console.error('Usage: create-project "<project name>" [orgName]');
process.exit(1);
}

const { apiKey, keyHash, keyPrefix } = mintApiKey();
const organizationId = await findOrCreateOrgByName(orgName);

const [project] = await db<{ id: string }[]>`
INSERT INTO projects (api_key_hash, name, key_prefix)
VALUES (${keyHash}, ${name}, ${keyPrefix})
INSERT INTO projects (api_key_hash, name, key_prefix, organization_id)
VALUES (${keyHash}, ${name}, ${keyPrefix}, ${organizationId})
RETURNING id
`;

// The key is shown exactly once. Print to stdout; everything else to stderr so
// `... | tail` style capture of just the key works.
console.error(`Created project "${name}" (id: ${project?.id})`);
console.error(`Created project "${name}" (id: ${project?.id}, org: ${orgName})`);
console.error('API key (store it now — it is not recoverable):');
console.log(apiKey);

Expand Down
Loading