@happyvertical/smrt-app-shell 0.0.0-stage → 0.60.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/AGENTS.md +99 -0
  2. package/CLAUDE.md +1 -0
  3. package/LICENSE +7 -0
  4. package/README.md +173 -2
  5. package/agents/engine.md +85 -0
  6. package/dist/engine/app-runtime.d.ts +138 -0
  7. package/dist/engine/app-runtime.js +545 -0
  8. package/dist/engine/app-worker.d.ts +1 -0
  9. package/dist/engine/app-worker.js +62 -0
  10. package/dist/engine/commands.d.ts +94 -0
  11. package/dist/engine/commands.js +30 -0
  12. package/dist/engine/definition.d.ts +192 -0
  13. package/dist/engine/definition.js +497 -0
  14. package/dist/engine/errors.d.ts +23 -0
  15. package/dist/engine/errors.js +35 -0
  16. package/dist/engine/host.d.ts +153 -0
  17. package/dist/engine/host.js +791 -0
  18. package/dist/engine/index.d.ts +6 -0
  19. package/dist/engine/index.js +7 -0
  20. package/dist/engine/sandbox.d.ts +60 -0
  21. package/dist/engine/sandbox.js +260 -0
  22. package/dist/engine/snapshots.d.ts +7 -0
  23. package/dist/engine/snapshots.js +54 -0
  24. package/dist/engine/store.d.ts +39 -0
  25. package/dist/engine/store.js +32 -0
  26. package/dist/engine/worker-handle.d.ts +29 -0
  27. package/dist/engine/worker-handle.js +89 -0
  28. package/dist/engine/worker-protocol.d.ts +22 -0
  29. package/dist/engine/worker-protocol.js +20 -0
  30. package/dist/lib/objects/App.d.ts +60 -0
  31. package/dist/lib/objects/App.js +273 -0
  32. package/dist/lib/objects/AppVersion.d.ts +22 -0
  33. package/dist/lib/objects/AppVersion.js +62 -0
  34. package/dist/lib/objects/Approval.d.ts +39 -0
  35. package/dist/lib/objects/Approval.js +101 -0
  36. package/dist/lib/objects/AssistantMessage.d.ts +24 -0
  37. package/dist/lib/objects/AssistantMessage.js +63 -0
  38. package/dist/lib/objects/AssistantThread.d.ts +16 -0
  39. package/dist/lib/objects/AssistantThread.js +44 -0
  40. package/dist/lib/objects/index.d.ts +5 -0
  41. package/dist/lib/objects/index.js +5 -0
  42. package/dist/lib/server/approvals.d.ts +69 -0
  43. package/dist/lib/server/approvals.js +239 -0
  44. package/dist/lib/server/builder.d.ts +16 -0
  45. package/dist/lib/server/builder.js +71 -0
  46. package/dist/lib/server/events.d.ts +16 -0
  47. package/dist/lib/server/events.js +14 -0
  48. package/dist/lib/server/host.d.ts +22 -0
  49. package/dist/lib/server/host.js +96 -0
  50. package/dist/lib/server/owner.d.ts +33 -0
  51. package/dist/lib/server/owner.js +92 -0
  52. package/dist/lib/server/version-store.d.ts +20 -0
  53. package/dist/lib/server/version-store.js +115 -0
  54. package/dist/manifest.json +1680 -0
  55. package/dist/smrt-knowledge.okf.md +95 -0
  56. package/package.json +80 -3
@@ -0,0 +1,44 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { field, SmrtCollection, SmrtObject, smrt, } from '@happyvertical/smrt-core';
8
+ /**
9
+ * One assistant conversation (shell.db). Written only by the assistant's
10
+ * thread store (`$lib/server/assistant/threads`), owner scoped; no generated
11
+ * surface.
12
+ */
13
+ let AssistantThread = class AssistantThread extends SmrtObject {
14
+ /** The owner's user id. */
15
+ ownerId = '';
16
+ title = '';
17
+ createdAt = '';
18
+ updatedAt = '';
19
+ };
20
+ __decorate([
21
+ field({ type: 'text' })
22
+ ], AssistantThread.prototype, "ownerId", void 0);
23
+ __decorate([
24
+ field({ type: 'text' })
25
+ ], AssistantThread.prototype, "title", void 0);
26
+ __decorate([
27
+ field({ type: 'text' })
28
+ ], AssistantThread.prototype, "createdAt", void 0);
29
+ __decorate([
30
+ field({ type: 'text' })
31
+ ], AssistantThread.prototype, "updatedAt", void 0);
32
+ AssistantThread = __decorate([
33
+ smrt({
34
+ tableName: 'assistant_threads',
35
+ api: false,
36
+ mcp: false,
37
+ cli: false,
38
+ conflictColumns: ['id'],
39
+ })
40
+ ], AssistantThread);
41
+ export { AssistantThread };
42
+ export class AssistantThreadCollection extends SmrtCollection {
43
+ static _itemClass = AssistantThread;
44
+ }
@@ -0,0 +1,5 @@
1
+ export { App, AppCollection } from './App.ts';
2
+ export { Approval, ApprovalCollection } from './Approval.ts';
3
+ export { AppVersion, AppVersionCollection } from './AppVersion.ts';
4
+ export { AssistantMessage, AssistantMessageCollection, } from './AssistantMessage.ts';
5
+ export { AssistantThread, AssistantThreadCollection, } from './AssistantThread.ts';
@@ -0,0 +1,5 @@
1
+ export { App, AppCollection } from "./App.js";
2
+ export { Approval, ApprovalCollection } from "./Approval.js";
3
+ export { AppVersion, AppVersionCollection } from "./AppVersion.js";
4
+ export { AssistantMessage, AssistantMessageCollection, } from "./AssistantMessage.js";
5
+ export { AssistantThread, AssistantThreadCollection, } from "./AssistantThread.js";
@@ -0,0 +1,69 @@
1
+ import type { Command, Plan } from '../../engine/index.ts';
2
+ import { type Approval } from '../objects/Approval.ts';
3
+ /** What an approval runs once the owner says yes. */
4
+ export type ApprovalAction = {
5
+ kind: 'command';
6
+ command: Command;
7
+ } | {
8
+ kind: 'tool';
9
+ name: string;
10
+ args: Record<string, unknown>;
11
+ } | {
12
+ kind: 'rest';
13
+ method: string;
14
+ url: string;
15
+ body?: string;
16
+ } | {
17
+ kind: 'record-delete';
18
+ model: string;
19
+ id: string;
20
+ };
21
+ /** The answer a refused-until-approved call gives instead of applying. */
22
+ export interface PendingApproval {
23
+ status: 'pending_approval';
24
+ approvalId: string;
25
+ summary: string;
26
+ preview: unknown;
27
+ }
28
+ /** Run `fn`, collecting every approval `requestApproval` issues inside it. */
29
+ export declare function collectIssuedApprovals<T>(fn: () => Promise<T>): Promise<{
30
+ value: T;
31
+ issued: PendingApproval[];
32
+ }>;
33
+ export interface ApprovalView {
34
+ id: string;
35
+ status: Approval['status'];
36
+ summary: string;
37
+ preview: unknown;
38
+ action: ApprovalAction | null;
39
+ requestedBy: string;
40
+ decidedBy: string;
41
+ decidedAt: string;
42
+ result: unknown;
43
+ createdAt: string;
44
+ }
45
+ export interface ApprovalOutcome {
46
+ id: string;
47
+ status: Approval['status'];
48
+ result?: unknown;
49
+ error?: string;
50
+ }
51
+ export declare function actionKey(action: ApprovalAction): string;
52
+ /**
53
+ * Record a pending approval for `action`. An identical pending action reuses
54
+ * its row, so retrying a refused call never piles up cards.
55
+ */
56
+ export declare function requestApproval(action: ApprovalAction, plan: Plan, requestedBy: string): Promise<PendingApproval>;
57
+ /** One approval as stored, or null. */
58
+ export declare function getApprovalView(id: string): Promise<ApprovalView | null>;
59
+ export declare function listApprovalViews(filter?: {
60
+ status?: Approval['status'];
61
+ }): Promise<ApprovalView[]>;
62
+ export declare function pendingApprovalCount(): Promise<number>;
63
+ /**
64
+ * Decide an approval. CALL ONLY FROM `Approval.decide` (which has checked the
65
+ * owner): this is the single place the engine's `approved: true` is set.
66
+ */
67
+ /** The failure text when the host RETURNED an error instead of throwing. */
68
+ export declare function returnedFailure(kind: string, result: unknown): string | undefined;
69
+ export declare function decideApproval(id: string, approve: boolean, decidedBy: string): Promise<ApprovalOutcome>;
@@ -0,0 +1,239 @@
1
+ // The approval gate. A risky builder command, app tool call or REST DELETE
2
+ // does not run: it becomes an `Approval` row for the owner. Only
3
+ // `decideApproval` (called from `Approval.decide`) ever passes the engine's
4
+ // `approved: true`; nothing taken from a request body can set it.
5
+ import { AsyncLocalStorage } from 'node:async_hooks';
6
+ import { createHash } from 'node:crypto';
7
+ import { ApprovalCollection } from "../objects/Approval.js";
8
+ import { publish } from "./events.js";
9
+ import { getHost, getShellDb } from "./host.js";
10
+ // Out-of-band record of the approvals a call issued. `requestApproval` is the
11
+ // only writer, so a caller (the assistant's turn loop) can tell "this call
12
+ // became an Approval" from a tool result whose text merely looks like one.
13
+ const issuedScope = new AsyncLocalStorage();
14
+ /** Run `fn`, collecting every approval `requestApproval` issues inside it. */
15
+ export async function collectIssuedApprovals(fn) {
16
+ const issued = [];
17
+ const value = await issuedScope.run(issued, fn);
18
+ return { value, issued };
19
+ }
20
+ const MAX_RESULT_CHARS = 64_000;
21
+ function canonical(value) {
22
+ if (Array.isArray(value))
23
+ return `[${value.map(canonical).join(',')}]`;
24
+ if (value && typeof value === 'object') {
25
+ const o = value;
26
+ return `{${Object.keys(o)
27
+ .filter((k) => o[k] !== undefined)
28
+ .sort()
29
+ .map((k) => `${JSON.stringify(k)}:${canonical(o[k])}`)
30
+ .join(',')}}`;
31
+ }
32
+ return JSON.stringify(value) ?? 'null';
33
+ }
34
+ export function actionKey(action) {
35
+ return createHash('sha256').update(canonical(action)).digest('hex');
36
+ }
37
+ function parseJson(text) {
38
+ if (!text)
39
+ return null;
40
+ try {
41
+ return JSON.parse(text);
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ /** The ORM may hydrate a timestamp-looking text column as a Date. */
48
+ function iso(value) {
49
+ return value instanceof Date ? value.toISOString() : String(value ?? '');
50
+ }
51
+ function view(row) {
52
+ return {
53
+ id: row.id,
54
+ status: row.status,
55
+ summary: row.summary,
56
+ preview: parseJson(row.preview),
57
+ action: parseJson(row.command),
58
+ requestedBy: row.requestedBy,
59
+ decidedBy: row.decidedBy,
60
+ decidedAt: iso(row.decidedAt),
61
+ result: parseJson(row.result),
62
+ createdAt: iso(row.createdAt),
63
+ };
64
+ }
65
+ async function approvals() {
66
+ return ApprovalCollection.create({ db: await getShellDb() });
67
+ }
68
+ /**
69
+ * Record a pending approval for `action`. An identical pending action reuses
70
+ * its row, so retrying a refused call never piles up cards.
71
+ */
72
+ export async function requestApproval(action, plan, requestedBy) {
73
+ const key = actionKey(action);
74
+ const coll = await approvals();
75
+ const existing = (await coll.list({ where: { actionKey: key, status: 'pending' }, limit: 1 }))[0];
76
+ const row = existing ??
77
+ (await (async () => {
78
+ const created = await coll.create({
79
+ command: JSON.stringify(action),
80
+ actionKey: key,
81
+ summary: plan.summary,
82
+ // Flat: the Approval card reads `preview.model`, `preview.code`, ...
83
+ preview: JSON.stringify({
84
+ ...(plan.preview ?? {}),
85
+ ...(plan.affectedMethods
86
+ ? { affectedMethods: plan.affectedMethods }
87
+ : {}),
88
+ }),
89
+ status: 'pending',
90
+ requestedBy,
91
+ createdAt: new Date().toISOString(),
92
+ });
93
+ await created.save();
94
+ publish({
95
+ type: 'approval',
96
+ id: created.id,
97
+ status: 'pending',
98
+ summary: plan.summary,
99
+ });
100
+ return created;
101
+ })());
102
+ const v = view(row);
103
+ const pending = {
104
+ status: 'pending_approval',
105
+ approvalId: v.id,
106
+ summary: v.summary,
107
+ preview: v.preview,
108
+ };
109
+ issuedScope.getStore()?.push(pending);
110
+ return pending;
111
+ }
112
+ /** One approval as stored, or null. */
113
+ export async function getApprovalView(id) {
114
+ const row = await (await approvals()).get(id);
115
+ return row ? view(row) : null;
116
+ }
117
+ export async function listApprovalViews(filter = {}) {
118
+ const coll = await approvals();
119
+ const rows = await coll.list({
120
+ ...(filter.status ? { where: { status: filter.status } } : {}),
121
+ limit: 500,
122
+ });
123
+ return rows.map(view).sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
124
+ }
125
+ export async function pendingApprovalCount() {
126
+ return (await listApprovalViews({ status: 'pending' })).length;
127
+ }
128
+ const inFlight = new Set();
129
+ function isAction(v) {
130
+ const a = v;
131
+ if (!a || typeof a !== 'object')
132
+ return false;
133
+ if (a.kind === 'command')
134
+ return !!a.command && typeof a.command.type === 'string';
135
+ if (a.kind === 'tool')
136
+ return typeof a.name === 'string' && typeof a.args === 'object';
137
+ if (a.kind === 'rest')
138
+ return typeof a.method === 'string' && typeof a.url === 'string';
139
+ if (a.kind === 'record-delete')
140
+ return typeof a.model === 'string' && typeof a.id === 'string';
141
+ return false;
142
+ }
143
+ /**
144
+ * Decide an approval. CALL ONLY FROM `Approval.decide` (which has checked the
145
+ * owner): this is the single place the engine's `approved: true` is set.
146
+ */
147
+ /** The failure text when the host RETURNED an error instead of throwing. */
148
+ export function returnedFailure(kind, result) {
149
+ const r = result;
150
+ if (kind === 'tool' && r?.isError) {
151
+ const first = Array.isArray(r.content)
152
+ ? r.content[0]
153
+ : undefined;
154
+ return first?.text?.slice(0, 500) || 'the tool reported an error';
155
+ }
156
+ if (kind === 'rest' && typeof r?.status === 'number') {
157
+ if (r.status < 200 || r.status > 299) {
158
+ return `the request failed with status ${r.status}`;
159
+ }
160
+ }
161
+ return undefined;
162
+ }
163
+ export async function decideApproval(id, approve, decidedBy) {
164
+ const coll = await approvals();
165
+ const row = await coll.get(id);
166
+ if (!row)
167
+ throw new Error('approval not found');
168
+ if (row.status !== 'pending' || inFlight.has(id))
169
+ throw new Error(`approval is already ${row.status}`);
170
+ inFlight.add(id);
171
+ try {
172
+ const decidedAt = new Date().toISOString();
173
+ if (!approve) {
174
+ row.status = 'denied';
175
+ row.decidedBy = decidedBy;
176
+ row.decidedAt = decidedAt;
177
+ await row.save();
178
+ publish({
179
+ type: 'approval',
180
+ id,
181
+ status: 'denied',
182
+ summary: row.summary,
183
+ });
184
+ return { id, status: 'denied' };
185
+ }
186
+ const action = parseJson(row.command);
187
+ if (!isAction(action))
188
+ throw new Error('the stored action is invalid');
189
+ // Mark it taken before running anything: a second decide sees "approved".
190
+ row.status = 'approved';
191
+ row.decidedBy = decidedBy;
192
+ row.decidedAt = decidedAt;
193
+ await row.save();
194
+ let result;
195
+ let error;
196
+ try {
197
+ const host = await getHost();
198
+ if (action.kind === 'command')
199
+ result = await host.execute(action.command, {
200
+ author: row.requestedBy || decidedBy,
201
+ approved: true,
202
+ });
203
+ else if (action.kind === 'tool')
204
+ result = await host.callAppTool(action.name, action.args, {
205
+ approved: true,
206
+ });
207
+ else if (action.kind === 'record-delete')
208
+ result = await host.remove(action.model, action.id);
209
+ else
210
+ result = await host.rest({ method: action.method, url: action.url, body: action.body }, { approved: true });
211
+ // The host reports some failures by returning them (a tool's isError,
212
+ // a non-2xx REST response) instead of throwing: they did not apply.
213
+ if (error === undefined)
214
+ error = returnedFailure(action.kind, result);
215
+ }
216
+ catch (e) {
217
+ error = e instanceof Error ? e.message : String(e);
218
+ }
219
+ row.status = error === undefined ? 'approved' : 'failed';
220
+ const text = JSON.stringify(error === undefined ? result : { error });
221
+ row.result =
222
+ text.length > MAX_RESULT_CHARS
223
+ ? JSON.stringify({ truncated: true })
224
+ : (text ?? '');
225
+ await row.save();
226
+ publish({
227
+ type: 'approval',
228
+ id,
229
+ status: row.status,
230
+ summary: row.summary,
231
+ });
232
+ return error === undefined
233
+ ? { id, status: 'approved', result }
234
+ : { id, status: 'failed', error };
235
+ }
236
+ finally {
237
+ inFlight.delete(id);
238
+ }
239
+ }
@@ -0,0 +1,16 @@
1
+ import { type Command, type ExecuteResult } from '../../engine/index.ts';
2
+ import { type PendingApproval } from './approvals.ts';
3
+ export type BuilderResult = ExecuteResult | PendingApproval;
4
+ /** Run a command for `author`. Never passes `approved` (see approvals.ts). */
5
+ export declare function runCommand(command: Command, author: string): Promise<BuilderResult>;
6
+ /** Run an app tool; a risky one (`<model>_delete`) becomes an approval. */
7
+ export declare function runAppTool(name: string, args: Record<string, unknown>, author: string): Promise<import("../../engine/host.ts").ToolResult | PendingApproval>;
8
+ /** Pass a request to the app's generated REST; a DELETE becomes an approval. */
9
+ export declare function runRest(req: {
10
+ method: string;
11
+ url: string;
12
+ body?: string;
13
+ }, author: string): Promise<import("../../engine/host.ts").RestResult | PendingApproval>;
14
+ export declare function isPending(x: unknown): x is PendingApproval;
15
+ /** Delete a record: always an approval (nothing is deleted until decided). */
16
+ export declare function requestRecordDelete(model: string, id: string, author: string): Promise<PendingApproval>;
@@ -0,0 +1,71 @@
1
+ // Builder operations: run an engine command, or, if it is risky, turn it into
2
+ // an Approval and return `pending_approval` without changing anything.
3
+ import { ApprovalRequiredError, } from "../../engine/index.js";
4
+ import { requestApproval, } from "./approvals.js";
5
+ import { getHost } from "./host.js";
6
+ /** Run a command for `author`. Never passes `approved` (see approvals.ts). */
7
+ export async function runCommand(command, author) {
8
+ const host = await getHost();
9
+ try {
10
+ return await host.execute(command, { author });
11
+ }
12
+ catch (e) {
13
+ if (e instanceof ApprovalRequiredError) {
14
+ // An undo is approved for the version it was planned against: if the app
15
+ // moves on first, approving it must not undo something else.
16
+ const version = e.plan.preview
17
+ ?.version;
18
+ const pinned = command.type === 'undo' && typeof version === 'number'
19
+ ? { type: 'undo', version }
20
+ : command;
21
+ return requestApproval({ kind: 'command', command: pinned }, e.plan, author);
22
+ }
23
+ throw e;
24
+ }
25
+ }
26
+ /** Run an app tool; a risky one (`<model>_delete`) becomes an approval. */
27
+ export async function runAppTool(name, args, author) {
28
+ const host = await getHost();
29
+ try {
30
+ return await host.callAppTool(name, args);
31
+ }
32
+ catch (e) {
33
+ if (e instanceof ApprovalRequiredError)
34
+ return requestApproval({ kind: 'tool', name, args }, e.plan, author);
35
+ throw e;
36
+ }
37
+ }
38
+ /** Pass a request to the app's generated REST; a DELETE becomes an approval. */
39
+ export async function runRest(req, author) {
40
+ const host = await getHost();
41
+ try {
42
+ return await host.rest(req);
43
+ }
44
+ catch (e) {
45
+ if (e instanceof ApprovalRequiredError) {
46
+ const action = {
47
+ kind: 'rest',
48
+ method: req.method.toUpperCase(),
49
+ url: req.url,
50
+ ...(req.body ? { body: req.body } : {}),
51
+ };
52
+ return requestApproval(action, e.plan, author);
53
+ }
54
+ throw e;
55
+ }
56
+ }
57
+ export function isPending(x) {
58
+ return (!!x &&
59
+ typeof x === 'object' &&
60
+ x.status === 'pending_approval');
61
+ }
62
+ /** Delete a record: always an approval (nothing is deleted until decided). */
63
+ export async function requestRecordDelete(model, id, author) {
64
+ const host = await getHost();
65
+ const record = await host.get(model, id); // 404s for a missing record
66
+ return requestApproval({ kind: 'record-delete', model, id }, {
67
+ summary: `Delete ${model} record ${id}`,
68
+ risky: true,
69
+ preview: { model, id, record },
70
+ }, author);
71
+ }
@@ -0,0 +1,16 @@
1
+ export type ShellEvent = {
2
+ type: 'changed';
3
+ version: number;
4
+ summary: string;
5
+ } | {
6
+ type: 'records';
7
+ model: string | null;
8
+ } | {
9
+ type: 'approval';
10
+ id: string;
11
+ status: 'pending' | 'approved' | 'denied' | 'failed';
12
+ summary: string;
13
+ };
14
+ export declare function publish(event: ShellEvent): void;
15
+ /** Subscribe; returns the unsubscribe function. */
16
+ export declare function subscribe(listener: (event: ShellEvent) => void): () => void;
@@ -0,0 +1,14 @@
1
+ // A small in-process event bus for live updates. Host events ('changed',
2
+ // 'records') and approval events are republished here; the SSE route
3
+ // (`/api/events`) and the MCP route (list_changed) subscribe.
4
+ import { EventEmitter } from 'node:events';
5
+ const bus = new EventEmitter();
6
+ bus.setMaxListeners(100);
7
+ export function publish(event) {
8
+ bus.emit('event', event);
9
+ }
10
+ /** Subscribe; returns the unsubscribe function. */
11
+ export function subscribe(listener) {
12
+ bus.on('event', listener);
13
+ return () => bus.off('event', listener);
14
+ }
@@ -0,0 +1,22 @@
1
+ import type { DatabaseInterface } from '@happyvertical/sql';
2
+ import { AppHost } from '../../engine/index.ts';
3
+ export interface ShellContext {
4
+ /** The shell's database (shell.db: users, App, AppVersion, Approval). */
5
+ db: () => Promise<DatabaseInterface>;
6
+ /** The app-runtime data root. */
7
+ dataRoot: () => Promise<string>;
8
+ /** Override the app worker entry (tests; defaults to the engine's own). */
9
+ workerUrl?: URL | string;
10
+ }
11
+ export declare function configureShell(next: ShellContext): void;
12
+ export declare function getShellDb(): Promise<DatabaseInterface>;
13
+ /** The shell database once `getShellDb()` has resolved. For sync thunks. */
14
+ export declare function shellDbSync(): DatabaseInterface;
15
+ export declare function dataRoot(): Promise<string>;
16
+ /** The one AppHost, opened on first use. */
17
+ export declare function getHost(): Promise<AppHost>;
18
+ /** The host if it is already open (sync, for list_changed wiring). */
19
+ export declare function openHost(): AppHost | null;
20
+ export declare function closeHost(): Promise<void>;
21
+ /** Tests: forget everything. */
22
+ export declare function resetShell(): Promise<void>;
@@ -0,0 +1,96 @@
1
+ // Process-wide holders: where the shell gets its database, its data root and
2
+ // the one `AppHost` (the user app's runtime). Everything server-side reaches
3
+ // them through here, so tests can configure a temp directory and a SQLite
4
+ // database without SvelteKit.
5
+ //
6
+ // Data layout: the host's `dataDir` is `<data root>/user-app`, a plain
7
+ // directory inside the app-runtime data root, so the encrypted backup that
8
+ // seals the root also seals app.db, snapshots and archives. No symlinks.
9
+ import { existsSync, mkdirSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { AppHost } from "../../engine/index.js";
13
+ import { publish } from "./events.js";
14
+ import { ShellVersionStore } from "./version-store.js";
15
+ let context = null;
16
+ let syncDb = null;
17
+ let hostPromise = null;
18
+ let hostRef = null;
19
+ let shutdownHooked = false;
20
+ export function configureShell(next) {
21
+ context = next;
22
+ }
23
+ function requireContext() {
24
+ if (!context)
25
+ throw new Error('the shell is not configured (call configureShell first)');
26
+ return context;
27
+ }
28
+ export async function getShellDb() {
29
+ const db = await requireContext().db();
30
+ syncDb = db;
31
+ return db;
32
+ }
33
+ /** The shell database once `getShellDb()` has resolved. For sync thunks. */
34
+ export function shellDbSync() {
35
+ if (!syncDb)
36
+ throw new Error('the shell database is not open yet');
37
+ return syncDb;
38
+ }
39
+ export function dataRoot() {
40
+ return requireContext().dataRoot();
41
+ }
42
+ function workerUrl() {
43
+ const configured = requireContext().workerUrl;
44
+ if (configured)
45
+ return configured;
46
+ // From source (dev, tests) the worker sits next to the engine; from a build
47
+ // it is the package's published engine worker.
48
+ const local = new URL('../../engine/app-worker.ts', import.meta.url);
49
+ if (existsSync(fileURLToPath(local)))
50
+ return local;
51
+ return new URL(import.meta.resolve('@happyvertical/smrt-app-shell/engine/worker'));
52
+ }
53
+ /** The one AppHost, opened on first use. */
54
+ export function getHost() {
55
+ hostPromise ??= (async () => {
56
+ const db = await getShellDb();
57
+ const dir = join(await dataRoot(), 'user-app');
58
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
59
+ const host = await AppHost.open(dir, {
60
+ store: new ShellVersionStore(db),
61
+ workerUrl: workerUrl(),
62
+ });
63
+ host.on('changed', (e) => publish({ type: 'changed', version: e.version, summary: e.summary }));
64
+ host.on('records', (e) => publish({ type: 'records', model: e.model }));
65
+ hostRef = host;
66
+ if (!shutdownHooked) {
67
+ shutdownHooked = true;
68
+ // adapter-node emits this on SIGTERM/SIGINT before exiting.
69
+ process.on('sveltekit:shutdown', () => {
70
+ void closeHost();
71
+ });
72
+ }
73
+ return host;
74
+ })();
75
+ hostPromise.catch(() => {
76
+ hostPromise = null; // let the next call retry
77
+ });
78
+ return hostPromise;
79
+ }
80
+ /** The host if it is already open (sync, for list_changed wiring). */
81
+ export function openHost() {
82
+ return hostRef;
83
+ }
84
+ export async function closeHost() {
85
+ const pending = hostPromise;
86
+ hostPromise = null;
87
+ hostRef = null;
88
+ if (pending)
89
+ await (await pending.catch(() => null))?.close();
90
+ }
91
+ /** Tests: forget everything. */
92
+ export async function resetShell() {
93
+ await closeHost();
94
+ context = null;
95
+ syncDb = null;
96
+ }
@@ -0,0 +1,33 @@
1
+ export interface Principal {
2
+ id: string;
3
+ tenantId: string;
4
+ scopes?: readonly string[];
5
+ }
6
+ export declare class PolicyError extends Error {
7
+ readonly status: 401 | 403;
8
+ constructor(status: 401 | 403, message: string);
9
+ }
10
+ export type OwnerCheck = (principal: Principal) => Promise<boolean>;
11
+ /** Tests only: replace the database lookup. Pass null to restore it. */
12
+ export declare function setOwnerCheck(check: OwnerCheck | null): void;
13
+ export declare function isOwner(principal: Principal | null | undefined): Promise<boolean>;
14
+ /** The principal, or a 401 (nobody) / 403 (somebody who is not the owner). */
15
+ export declare function requireOwner(principal: Principal | null | undefined): Promise<Principal>;
16
+ /** The principal of a SvelteKit request, from the verified session only. */
17
+ export declare function principalFromLocals(locals: {
18
+ user?: {
19
+ id?: unknown;
20
+ } | null;
21
+ tenantId?: unknown;
22
+ permissions?: unknown;
23
+ }): Principal | null;
24
+ /** The principal of the running async scope (the session's tenant context). */
25
+ export declare function currentPrincipal(): Principal | null;
26
+ /** For model methods: the owner of the current scope, or a refusal. */
27
+ export declare function assertOwner(): Promise<Principal>;
28
+ /**
29
+ * Run `fn` inside the verified principal's tenant context, so the owner check
30
+ * in model methods (`assertOwner`) sees exactly the principal a route
31
+ * verified, whatever async context the route runs in.
32
+ */
33
+ export declare function runAs<T>(principal: Principal, fn: () => Promise<T>): Promise<T>;