feat(database): add account-scoped database manager
This commit is contained in:
1 parent
1606c41f43
commit
33e80a566e
18 files changed
+444
-25
No files matched your search
+5
-4
@@ -7,22 +7,23 @@ const meta = new URL("../dist/.openai/", import.meta.url);
|
||||
|
||||
await rm(dist, { recursive: true, force: true });
|
||||
await Promise.all([mkdir(server, { recursive: true }), mkdir(meta, { recursive: true })]);
|
||||
const [rawIndexHtml, addonsJs, bootGuardJs, i18nJs, tasksJs, calendarJs, i18nRenderersJs, desktopPersonalizationJs, desktopWidgetsJs, browserAppJs, dataActionsJs, terminalAppJs, manifest, serviceWorker, icon, localeEn, localeZh] = await Promise.all([text("index.html"), text("addons.js"), text("boot-guard.js"), text("i18n.js"), text("tasks.js"), text("calendar.js"), text("i18n-renderers.js"), text("desktop-personalization.js"), text("desktop-widgets.js"), text("browser-app.js"), text("data-actions.js"), text("terminal-app.js"), text("manifest.webmanifest"), text("service-worker.js"), text("life-os-icon.svg"), readFile(new URL("../public/locales/en.json", import.meta.url), "utf8"), readFile(new URL("../public/locales/zh-CN.json", import.meta.url), "utf8")]);
|
||||
const release = "1.13.0";
|
||||
const [rawIndexHtml, addonsJs, bootGuardJs, i18nJs, tasksJs, calendarJs, i18nRenderersJs, desktopPersonalizationJs, desktopWidgetsJs, browserAppJs, dataActionsJs, terminalAppJs, dbAdminJs, manifest, serviceWorker, icon, localeEn, localeZh] = await Promise.all([text("index.html"), text("addons.js"), text("boot-guard.js"), text("i18n.js"), text("tasks.js"), text("calendar.js"), text("i18n-renderers.js"), text("desktop-personalization.js"), text("desktop-widgets.js"), text("browser-app.js"), text("data-actions.js"), text("terminal-app.js"), text("db-admin.js"), text("manifest.webmanifest"), text("service-worker.js"), text("life-os-icon.svg"), readFile(new URL("../public/locales/en.json", import.meta.url), "utf8"), readFile(new URL("../public/locales/zh-CN.json", import.meta.url), "utf8")]);
|
||||
const release = "1.14.0";
|
||||
const localeEnJs = `window.LifeOSLocales=window.LifeOSLocales||{};window.LifeOSLocales.en=${localeEn};`;
|
||||
const localeZhJs = `window.LifeOSLocales=window.LifeOSLocales||{};window.LifeOSLocales['zh-CN']=${localeZh};`;
|
||||
let indexHtml = rawIndexHtml.replace('src="addons.js"', `src="addons.js?v=${release}"`).replace('src="tasks.js"', `src="tasks.js?v=${release}"`).replace('src="calendar.js"', `src="calendar.js?v=${release}"`);
|
||||
for (const [src, code] of [
|
||||
['boot-guard.js', bootGuardJs], ['locales/en.js', localeEnJs], ['locales/zh-CN.js', localeZhJs],
|
||||
['i18n.js', i18nJs], ['tasks.js', tasksJs], ['calendar.js', calendarJs],
|
||||
['addons.js', addonsJs], ['i18n-renderers.js', i18nRenderersJs], ['desktop-personalization.js', desktopPersonalizationJs], ['desktop-widgets.js', desktopWidgetsJs], ['browser-app.js', browserAppJs], ['data-actions.js', dataActionsJs], ['terminal-app.js', terminalAppJs]
|
||||
['addons.js', addonsJs], ['i18n-renderers.js', i18nRenderersJs], ['desktop-personalization.js', desktopPersonalizationJs], ['desktop-widgets.js', desktopWidgetsJs], ['browser-app.js', browserAppJs], ['data-actions.js', dataActionsJs], ['terminal-app.js', terminalAppJs], ['db-admin.js', dbAdminJs]
|
||||
]) {
|
||||
const tag = `<script src="${src}${src === 'boot-guard.js' ? '' : `?v=${release}`}"></script>`;
|
||||
if (!indexHtml.includes(tag)) throw new Error(`Missing Life OS script: ${src}`);
|
||||
indexHtml = indexHtml.replace(tag, `<script>${code.replace(/<\/script/gi, '<\\/script')}</script>`);
|
||||
}
|
||||
await writeFile(new URL("assets.js", server), `export const indexHtml=${JSON.stringify(indexHtml)};\nexport const addonsJs=${JSON.stringify(addonsJs)};\nexport const bootGuardJs=${JSON.stringify(bootGuardJs)};\nexport const i18nJs=${JSON.stringify(i18nJs)};\nexport const localeEn=JSON.parse(${JSON.stringify(localeEn)});\nexport const localeZh=JSON.parse(${JSON.stringify(localeZh)});\nexport const localeEnJs=${JSON.stringify(localeEnJs)};\nexport const localeZhJs=${JSON.stringify(localeZhJs)};\nexport const tasksJs=${JSON.stringify(tasksJs)};\nexport const calendarJs=${JSON.stringify(calendarJs)};\nexport const i18nRenderersJs=${JSON.stringify(i18nRenderersJs)};\nexport const desktopPersonalizationJs=${JSON.stringify(desktopPersonalizationJs)};\nexport const desktopWidgetsJs=${JSON.stringify(desktopWidgetsJs)};\nexport const browserAppJs=${JSON.stringify(browserAppJs)};\nexport const dataActionsJs=${JSON.stringify(dataActionsJs)};\nexport const terminalAppJs=${JSON.stringify(terminalAppJs)};\nexport const manifest=${JSON.stringify(manifest)};\nexport const serviceWorker=${JSON.stringify(serviceWorker)};\nexport const icon=${JSON.stringify(icon)};\n`);
|
||||
await writeFile(new URL("assets.js", server), `export const indexHtml=${JSON.stringify(indexHtml)};\nexport const addonsJs=${JSON.stringify(addonsJs)};\nexport const bootGuardJs=${JSON.stringify(bootGuardJs)};\nexport const i18nJs=${JSON.stringify(i18nJs)};\nexport const localeEn=JSON.parse(${JSON.stringify(localeEn)});\nexport const localeZh=JSON.parse(${JSON.stringify(localeZh)});\nexport const localeEnJs=${JSON.stringify(localeEnJs)};\nexport const localeZhJs=${JSON.stringify(localeZhJs)};\nexport const tasksJs=${JSON.stringify(tasksJs)};\nexport const calendarJs=${JSON.stringify(calendarJs)};\nexport const i18nRenderersJs=${JSON.stringify(i18nRenderersJs)};\nexport const desktopPersonalizationJs=${JSON.stringify(desktopPersonalizationJs)};\nexport const desktopWidgetsJs=${JSON.stringify(desktopWidgetsJs)};\nexport const browserAppJs=${JSON.stringify(browserAppJs)};\nexport const dataActionsJs=${JSON.stringify(dataActionsJs)};\nexport const terminalAppJs=${JSON.stringify(terminalAppJs)};\nexport const dbAdminJs=${JSON.stringify(dbAdminJs)};\nexport const manifest=${JSON.stringify(manifest)};\nexport const serviceWorker=${JSON.stringify(serviceWorker)};\nexport const icon=${JSON.stringify(icon)};\n`);
|
||||
await copyFile(new URL("../worker/index.js", import.meta.url), new URL("index.js", server));
|
||||
await copyFile(new URL("../worker/cloud-state.js", import.meta.url), new URL("cloud-state.js", server));
|
||||
await copyFile(new URL("../worker/db-admin.js", import.meta.url), new URL("db-admin.js", server));
|
||||
await copyFile(new URL("../.openai/hosting.json", import.meta.url), new URL("hosting.json", meta));
|
||||
console.log("Built Life OS Worker artifact");
|
||||
+22
-1
@@ -8,6 +8,7 @@ const sqlite = new DatabaseSync(":memory:");
|
||||
sqlite.exec("PRAGMA foreign_keys = ON");
|
||||
sqlite.exec(readFileSync(new URL("../drizzle/0000_lifeos_cloud_state.sql", import.meta.url), "utf8"));
|
||||
sqlite.exec(readFileSync(new URL("../drizzle/0001_lifeos_records.sql", import.meta.url), "utf8"));
|
||||
sqlite.exec(readFileSync(new URL("../drizzle/0002_lifeos_db_admin.sql", import.meta.url), "utf8"));
|
||||
const db = {
|
||||
prepare(sql) {
|
||||
return {
|
||||
@@ -36,7 +37,7 @@ async function call(path, method = "GET", data, as = headers) {
|
||||
const response = await worker.fetch(new Request("https://life-os.test" + path, {
|
||||
method, headers: { ...as, "content-type": "application/json" },
|
||||
body: data === undefined ? undefined : JSON.stringify(data)
|
||||
}), { DB: db });
|
||||
}), { DB: db, LIFEOS_DB_ADMIN_PASSWORD: "local test password with 28 chars" });
|
||||
return { status: response.status, body: await response.json() };
|
||||
}
|
||||
|
||||
@@ -102,4 +103,24 @@ const pending = client.window.CloudTest.changes(snapshot.state, client.window.Cl
|
||||
assert.deepEqual(Array.from(pending, item => item.kind), ["tasks"], "editing a task only writes one record");
|
||||
await client.window.CloudTest.sync();
|
||||
assert.equal((await call("/api/lifeos-state")).body.state.system.tasksBoard.tasks[0].done, true);
|
||||
assert.equal((await call("/api/db-admin/tables/tasks/rows")).status, 401);
|
||||
assert.equal((await call("/api/db-admin/login", "POST", { username: "lifeos-admin", password: "bad", acknowledged: true })).status, 401);
|
||||
const login = await call("/api/db-admin/login", "POST", {
|
||||
username: "lifeos-admin", password: "local test password with 28 chars", acknowledged: true
|
||||
});
|
||||
assert.equal(login.status, 200);
|
||||
const auth = { ...headers, "x-lifeos-admin-session": login.body.token };
|
||||
assert.equal((await call("/api/db-admin/tables/tasks/rows", "GET", undefined, { ...auth, origin: "https://evil.example" })).status, 403);
|
||||
let admin = await call("/api/db-admin/tables/tasks/rows", "GET", undefined, auth);
|
||||
assert.equal(admin.status, 200);
|
||||
assert.equal(admin.body.total, 2);
|
||||
assert.equal((await call("/api/db-admin/tables/tasks/rows", "GET", undefined, { ...auth, "oai-authenticated-user-id": "bob" })).status, 401, "admin session is tied to one user");
|
||||
admin = await call("/api/db-admin/tables/tasks/rows/t3", "POST", { baseVersion: 0, value: { id: "t3", title: "From database" } }, auth);
|
||||
assert.equal(admin.body.version, 1);
|
||||
admin = await call("/api/db-admin/tables/tasks/rows/t3", "PATCH", { baseVersion: 1, value: { id: "t3", title: "Edited" } }, auth);
|
||||
assert.equal(admin.body.version, 2);
|
||||
assert.equal((await call("/api/db-admin/tables/tasks/rows/t3", "PATCH", { baseVersion: 1, value: { id: "t3", title: "Stale" } }, auth)).status, 409);
|
||||
assert.equal((await call("/api/db-admin/tables/tasks/rows/t3?version=2", "DELETE", undefined, auth)).status, 200);
|
||||
assert.equal((await call("/api/db-admin/logout", "POST", {}, auth)).status, 200);
|
||||
assert.equal((await call("/api/db-admin/tables/tasks/rows", "GET", undefined, auth)).status, 401);
|
||||
console.log("Life OS record migration, sync conflict, tags, and user isolation valid");
|
||||
+13
-3
@@ -50,11 +50,21 @@ assert.doesNotMatch(assets.indexHtml, /setTimeout\(applyLanguage\s*,/);
|
||||
assert.doesNotMatch(assets.addonsJs, /(?<![\w.])applyLanguage\s*\(/, "add-on actions must not call a missing global locale function");
|
||||
assert.match(assets.indexHtml, /window\.renderIcons=renderIcons;window\.renderAll=renderAll/, "shared renderers are explicitly exposed to locale scripts");
|
||||
const shippedScripts = [...assets.indexHtml.matchAll(/<script>([\s\S]*?)<\/script>/g)].map(match => match[1]);
|
||||
assert.equal(shippedScripts.length, 15, "the complete desktop script chain is present in the served HTML");
|
||||
assert.equal(shippedScripts.length, 16, "the complete desktop script chain is present in the served HTML");
|
||||
assert.doesNotMatch(assets.indexHtml, /<script\s+src=/, "the desktop cannot silently fall back when an app script fails to load");
|
||||
assert.ok(assets.indexHtml.indexOf('window.systemApp =') < assets.indexHtml.lastIndexOf("window.init()"), "locale renderers run before desktop initialization");
|
||||
for (const script of shippedScripts) new vm.Script(script);
|
||||
for (const script of [assets.i18nJs, assets.dataActionsJs, assets.terminalAppJs, assets.tasksJs, assets.calendarJs, assets.i18nRenderersJs, assets.desktopPersonalizationJs, assets.desktopWidgetsJs, assets.browserAppJs, assets.addonsJs]) new vm.Script(script);
|
||||
for (const script of [assets.i18nJs, assets.dataActionsJs, assets.terminalAppJs, assets.dbAdminJs, assets.tasksJs, assets.calendarJs, assets.i18nRenderersJs, assets.desktopPersonalizationJs, assets.desktopWidgetsJs, assets.browserAppJs, assets.addonsJs]) new vm.Script(script);
|
||||
const adminNode = { innerHTML: "", querySelector: () => ({ addEventListener() {} }) };
|
||||
const adminContext = vm.createContext({
|
||||
document: { createElement: () => ({ textContent: "" }), head: { append() {} }, querySelector: () => adminNode },
|
||||
LifeI18n: { t: key => key }, window: {}, Date
|
||||
});
|
||||
vm.runInContext(assets.dbAdminJs, adminContext);
|
||||
adminContext.window.databaseApp(adminNode);
|
||||
assert.match(adminNode.innerHTML, /lifeos-admin/);
|
||||
assert.match(adminNode.innerHTML, /type="password"/);
|
||||
assert.match(adminNode.innerHTML, /name="acknowledged" required/);
|
||||
const browserNodes = Object.fromEntries(['.browser-toolbar', '.browser-address', 'iframe', '[data-action="back"]', '[data-action="forward"]', '.browser-external', '.browser-hint-link'].map(key => [key, { value: '', href: '', disabled: false, listeners: {}, addEventListener(type, handler) { this.listeners[type] = handler; }, getAttribute(name) { return this[name] ?? null; } }]));
|
||||
const browserContainer = { dataset: {}, innerHTML: '', querySelector(selector) { return selector === '.browser-shell' ? (this.innerHTML ? {} : null) : browserNodes[selector]; } };
|
||||
const browserMessages = [];
|
||||
@@ -173,7 +183,7 @@ assert.ok(command('date').message.length > 8);
|
||||
assert.ok(command('time').message.includes(':'));
|
||||
assert.ok(command('datetime').message.length > 12);
|
||||
assert.equal(command('uptime').message, 'terminal.uptime');
|
||||
assert.equal(command('version').message, 'Life OS v1.13.0');
|
||||
assert.equal(command('version').message, 'Life OS v1.14.0');
|
||||
assert.equal(command('status').message, 'terminal.status');
|
||||
assert.match(command('apps').message, /calendar/);
|
||||
assert.equal(command('echo "hello Life OS"').message, 'hello Life OS');
|
||||
|
||||
Reference in new issue
Block a user