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
32 changes: 32 additions & 0 deletions apps/web/src/backend/api/deployment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Fox Schema (foxschema)
* Copyright 2024-2026 Huy Phan <huyplb@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, describe, expect, it } from 'vitest';
import { isLocalSingleUser } from './deployment';

const original = process.env.LOCAL_SINGLE_USER;

afterEach(() => {
if (original === undefined) delete process.env.LOCAL_SINGLE_USER;
else process.env.LOCAL_SINGLE_USER = original;
});

describe('isLocalSingleUser', () => {
it('defaults to true and is read per call, not captured at import', () => {
delete process.env.LOCAL_SINGLE_USER;
expect(isLocalSingleUser()).toBe(true);
// A module-load snapshot would keep returning true here, and the routes
// that gate on it would stay open on a multi-user deployment.
process.env.LOCAL_SINGLE_USER = 'false';
expect(isLocalSingleUser()).toBe(false);
process.env.LOCAL_SINGLE_USER = 'true';
expect(isLocalSingleUser()).toBe(true);
});

it('only the exact string "false" opts out', () => {
process.env.LOCAL_SINGLE_USER = '0';
expect(isLocalSingleUser()).toBe(true);
});
});
21 changes: 21 additions & 0 deletions apps/web/src/backend/api/deployment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Fox Schema (foxschema)
* Copyright 2024-2026 Huy Phan <huyplb@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*
* Which deployment shape this process is running as.
*
* The answer decides more than which auth guard is installed: a handful of
* routes are only safe when the caller is the person sitting at the machine
* (probing an arbitrary metadata-DB URL, installing a driver, self-updating).
* Those checks used to be described in comments while the code did nothing, so
* the predicate lives here where a route can actually call it.
*
* Read per call rather than captured at import: tests flip the variable, and a
* module-load snapshot silently ignores them.
*/

/** Default is single-user (no login). `LOCAL_SINGLE_USER=false` opts out. */
export function isLocalSingleUser(): boolean {
return process.env.LOCAL_SINGLE_USER !== 'false';
}
70 changes: 67 additions & 3 deletions apps/web/src/backend/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { createMetadataStore } from '../database/stores/registry';
import { keySchemeInfo } from '../cores/crypto';
import type { AuthedRequest } from './auth.routes';
import { denyUnless, requirePermissions } from './rbac.middleware';
import { isLocalSingleUser } from './deployment';
import { CATEGORY_PERMISSION, DATAGRID_ACTION_PERMISSION, isDatagridAction, permissionSatisfied, type Permission } from '../../shared/permissions';
import { toHttpError, type ActorContext } from '../features/actor';
import { makeConnectionResolver, type ConnectionRef } from '../features/connections/resolve';
Expand Down Expand Up @@ -202,6 +203,18 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt
// Restricted to the local/community edition — on multi-user web the metadata
// DB is ops-managed, and a connection probe would be an SSRF vector.
router.post('/db/test', async (req: Request, res: Response) => {
// The restriction above was documented but never implemented. On a
// multi-user deployment this handler dials any host:port the caller names
// and reports, through the error text, whether something answered — an
// SSRF and internal port-scan primitive, on a route that carries no
// permission check. Local single-user is the only place it belongs.
if (!isLocalSingleUser()) {
res.status(403).json({
ok: false,
error: 'Changing the metadata database is not available on this deployment.',
});
return;
}
const { engine, url, path } = req.body as { engine?: string; url?: string; path?: string };
if (!engine || !SUPPORTED_ENGINES.includes(engine as DbEngine)) {
res.status(400).json({ ok: false, error: `Unsupported engine. Supported: ${SUPPORTED_ENGINES.join(', ')}.` });
Expand Down Expand Up @@ -997,6 +1010,28 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt
res.json(result);
});

// Version-to-version diff, served from the object store — no connection to
// the compared database is needed or opened.
router.get('/lokee/databases/:id/compare', async (req: Request, res: Response) => {
const versionId = String(req.query.versionId ?? '').trim();
if (!versionId) {
res.status(400).json({ error: 'versionId is required' });
return;
}
const against = String(req.query.againstVersionId ?? '').trim();
const result = await lokeeWeave.diffVersions(
(req as AuthedRequest).userId!,
String(req.params.id),
versionId,
against || undefined
);
if (!result) {
res.status(404).json({ error: 'Version not found' });
return;
}
res.json(result);
});

router.get(
'/lokee/databases/:id/revert/plan',
requirePermissions('schema.browse'),
Expand All @@ -1006,10 +1041,24 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt
res.status(400).json({ error: 'toVersionId is required' });
return;
}
// Optional selective revert: `?objectKeys=a&objectKeys=b`, or omitted for
// the whole schema.
// Absent means "whole schema"; present-but-empty means "nothing", and
// those must stay distinguishable all the way down.
const objectKeys =
req.query.objectKeys === undefined
? undefined
: ([] as string[])
.concat(req.query.objectKeys as string | string[])
.map((k) => String(k).trim())
.filter(Boolean);
const plan = await lokeeWeave.planRevert(
(req as AuthedRequest).userId!,
String(req.params.id),
toVersionId
toVersionId,
undefined,
undefined,
objectKeys
);
if (!plan) {
res.status(404).json({ error: 'Version not found' });
Expand All @@ -1025,7 +1074,12 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt
lokeeCaptureLimiter,
requirePermissions('schema.migrate'),
async (req: Request, res: Response) => {
const body = req.body as ConnectionRef & { toVersionId?: string; confirmLossy?: boolean };
const body = req.body as ConnectionRef & {
toVersionId?: string;
confirmLossy?: boolean;
/** Revert only these objects; omit for the whole schema. */
objectKeys?: string[];
};
const toVersionId = String(body.toVersionId ?? '').trim();
if (!toVersionId) {
res.status(400).json({ error: 'toVersionId is required' });
Expand Down Expand Up @@ -1066,7 +1120,17 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt
return;
}

const plan = await lokeeWeave.planRevert(userId, databaseId, toVersionId, dialect, schema);
const objectKeys = Array.isArray(body.objectKeys)
? body.objectKeys.map((k) => String(k).trim()).filter(Boolean)
: undefined;
const plan = await lokeeWeave.planRevert(
userId,
databaseId,
toVersionId,
dialect,
schema,
objectKeys
);
if (!plan) {
res.status(404).json({ error: 'Version not found' });
return;
Expand Down
29 changes: 29 additions & 0 deletions apps/web/src/backend/database/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,35 @@ const MIGRATIONS: Migration[] = [
];
},
},
{
id: 14,
name: 'lokee_shape_dedup',
statements: (d) => {
const t = types(d);
return [
// Reusable half of an object body. An object body carries its own name
// and table, so `int not null` in forty tables was forty near-identical
// rows; the declaration is now stored once and pointed at.
//
// The object hash is unchanged — it is still computed over the whole
// body. This is a storage layout, not a change of identity, or every
// recorded version would need rehashing.
`CREATE TABLE IF NOT EXISTS lokee_shapes (
shape_hash ${t.id} PRIMARY KEY,
shape_json ${t.big} NOT NULL,
created_at ${t.ts} NOT NULL
)`,
// Null for rows written before this migration and for any body that
// does not round-trip; those keep their whole body in body_json.
`ALTER TABLE lokee_objects ADD COLUMN shape_hash ${t.id}`,
// The history read path filters children by key prefix (LIKE
// 'column:OWNER.%'), which scans without this.
`CREATE INDEX idx_lokee_objects_key ON lokee_objects(object_key)`,
// Graph reconstruction walks deltas newest-first per version.
`CREATE INDEX idx_lokee_version_objects_hash ON lokee_version_objects(object_hash)`,
];
},
},
];

const SIGNUP_WIZARD_SHOWN_KEY = 'signup.wizard_shown';
Expand Down
Loading
Loading