@happyvertical/smrt-template-sveltekit 0.43.9 → 0.44.0

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 (41) hide show
  1. package/AGENTS.md +31 -6
  2. package/README.md +26 -10
  3. package/index.js +53 -2
  4. package/package.json +19 -5
  5. package/template/.dockerignore +12 -0
  6. package/template/.env.cloud.example +9 -0
  7. package/template/.env.example +15 -3
  8. package/template/.env.self-hosted.example +11 -0
  9. package/template/AGENTS.md +32 -0
  10. package/template/Dockerfile +23 -0
  11. package/template/INSTALL_PROMPT.md +11 -0
  12. package/template/README.md +74 -32
  13. package/template/compose.yaml +56 -0
  14. package/template/gitignore.template +45 -0
  15. package/template/package.json +30 -15
  16. package/template/scripts/smrt-app.mjs +884 -0
  17. package/template/scripts/smrt-operation-lock.mjs +102 -0
  18. package/template/scripts/smrt-portability-assets.mjs +707 -0
  19. package/template/scripts/smrt-portability.mjs +470 -0
  20. package/template/scripts/smrt-prepare-migration.mjs +93 -0
  21. package/template/scripts/smrt-process.mjs +82 -0
  22. package/template/scripts/smrt-provider-readiness.mjs +33 -0
  23. package/template/scripts/smrt-runtime-identity.mjs +238 -0
  24. package/template/scripts/smrt-vite.mjs +45 -0
  25. package/template/scripts/smrt-web.mjs +10 -0
  26. package/template/scripts/smrt-worker.mjs +86 -0
  27. package/template/scripts/smrt-writer-lease.mjs +199 -0
  28. package/template/smrt.config.ts +34 -2
  29. package/template/src/hooks.server.ts +4 -1
  30. package/template/src/lib/RuntimeDiagnosticsWebMcp.svelte +11 -0
  31. package/template/src/lib/runtime-diagnostics-webmcp.ts +108 -0
  32. package/template/src/lib/server/application-runtime.ts +262 -0
  33. package/template/src/lib/server/runtime-diagnostics.ts +114 -0
  34. package/template/src/lib/server/smrt.ts +4 -4
  35. package/template/src/routes/+layout.svelte +2 -0
  36. package/template/src/routes/api/_runtime/diagnostics/+server.ts +5 -0
  37. package/template/src/routes/api/_runtime/health/+server.ts +30 -0
  38. package/template/src/routes/setup/+page.server.ts +96 -0
  39. package/template/src/routes/setup/+page.svelte +31 -0
  40. package/template/svelte.config.js +1 -1
  41. package/template/tsconfig.json +1 -0
@@ -0,0 +1,108 @@
1
+ export const RUNTIME_DIAGNOSTICS_WEBMCP_TOOL_NAME =
2
+ 'smrt.runtime.diagnostics.read';
3
+
4
+ interface RuntimeDiagnosticsTool {
5
+ readonly name: string;
6
+ readonly description: string;
7
+ readonly inputSchema: Record<string, unknown>;
8
+ readonly annotations: {
9
+ readonly readOnlyHint: true;
10
+ readonly destructiveHint: false;
11
+ readonly idempotentHint: true;
12
+ readonly openWorldHint: false;
13
+ };
14
+ readonly execute: (args: Record<string, unknown>) => Promise<string>;
15
+ }
16
+
17
+ interface RuntimeDiagnosticsModelContext {
18
+ registerTool(
19
+ tool: RuntimeDiagnosticsTool,
20
+ options: { signal: AbortSignal },
21
+ ): void | Promise<void>;
22
+ }
23
+
24
+ export interface RuntimeDiagnosticsWebMcpOwner {
25
+ readonly dispose: () => void;
26
+ }
27
+
28
+ export interface RegisterRuntimeDiagnosticsWebMcpOptions {
29
+ readonly fetchFn?: typeof fetch;
30
+ readonly modelContext?: RuntimeDiagnosticsModelContext;
31
+ }
32
+
33
+ /** Register exactly one page-user, read-only diagnostics tool for its owner. */
34
+ export function registerRuntimeDiagnosticsWebMcp(
35
+ options: RegisterRuntimeDiagnosticsWebMcpOptions = {},
36
+ ): RuntimeDiagnosticsWebMcpOwner | null {
37
+ const modelContext =
38
+ options.modelContext ??
39
+ (globalThis as {
40
+ document?: { modelContext?: RuntimeDiagnosticsModelContext };
41
+ }).document?.modelContext;
42
+ if (!modelContext || typeof modelContext.registerTool !== 'function') {
43
+ return null;
44
+ }
45
+
46
+ const controller = new AbortController();
47
+ const tool = createRuntimeDiagnosticsWebMcpTool(options.fetchFn ?? fetch);
48
+ try {
49
+ const registration = modelContext.registerTool(tool, {
50
+ signal: controller.signal,
51
+ });
52
+ void Promise.resolve(registration).catch(() => controller.abort());
53
+ } catch {
54
+ controller.abort();
55
+ }
56
+ return Object.freeze({ dispose: () => controller.abort() });
57
+ }
58
+
59
+ export function createRuntimeDiagnosticsWebMcpTool(
60
+ fetchFn: typeof fetch,
61
+ ): RuntimeDiagnosticsTool {
62
+ return Object.freeze({
63
+ name: RUNTIME_DIAGNOSTICS_WEBMCP_TOOL_NAME,
64
+ description:
65
+ 'Read the authenticated, redacted application runtime diagnostics.',
66
+ inputSchema: Object.freeze({
67
+ type: 'object',
68
+ properties: Object.freeze({}),
69
+ additionalProperties: false,
70
+ }),
71
+ annotations: Object.freeze({
72
+ readOnlyHint: true as const,
73
+ destructiveHint: false as const,
74
+ idempotentHint: true as const,
75
+ openWorldHint: false as const,
76
+ }),
77
+ async execute() {
78
+ let response: Response;
79
+ try {
80
+ response = await fetchFn('/api/_runtime/diagnostics', {
81
+ method: 'GET',
82
+ credentials: 'same-origin',
83
+ headers: { accept: 'application/json' },
84
+ });
85
+ } catch {
86
+ return stableToolError('diagnostics_unavailable');
87
+ }
88
+ if (!response.ok) {
89
+ return stableToolError(
90
+ response.status === 401
91
+ ? 'authentication_required'
92
+ : response.status === 403
93
+ ? 'authorization_denied'
94
+ : 'diagnostics_unavailable',
95
+ );
96
+ }
97
+ try {
98
+ return JSON.stringify(await response.json());
99
+ } catch {
100
+ return stableToolError('diagnostics_unavailable');
101
+ }
102
+ },
103
+ });
104
+ }
105
+
106
+ function stableToolError(code: string): string {
107
+ return JSON.stringify({ ok: false, error: { code } });
108
+ }
@@ -0,0 +1,262 @@
1
+ import {
2
+ initializeDeployedApplicationRuntime,
3
+ initializeLocalApplicationRuntime,
4
+ projectRuntimeDiagnostics,
5
+ resolveLocalRuntimePaths,
6
+ type LocalApplicationRuntime,
7
+ type RuntimeDiagnostics,
8
+ type RuntimeDiagnosticsProjectionInput,
9
+ } from '@happyvertical/smrt-app-runtime';
10
+ import {
11
+ loadConfig,
12
+ resolveApplicationRuntime,
13
+ resolveConfiguredApplicationRuntime,
14
+ } from '@happyvertical/smrt-config';
15
+ import type { SmrtClassOptions } from '@happyvertical/smrt-core';
16
+ import { getDatabase } from '@happyvertical/sql';
17
+ import {
18
+ prepareApplicationStateRoot,
19
+ resolveApplicationId,
20
+ runtimeConfigurationFingerprint,
21
+ } from '../../../scripts/smrt-runtime-identity.mjs';
22
+ import { acquireWriterLease } from '../../../scripts/smrt-writer-lease.mjs';
23
+ import { createProviderReadinessProbe } from '../../../scripts/smrt-provider-readiness.mjs';
24
+
25
+ const loadedConfig = await loadConfig();
26
+
27
+ export const applicationRuntime = loadedConfig.runtime
28
+ ? resolveConfiguredApplicationRuntime()
29
+ : resolveApplicationRuntime({ profile: 'local' });
30
+ const appId = resolveApplicationId({
31
+ sourceRoot: process.cwd(),
32
+ explicitId: process.env.SMRT_APP_ID,
33
+ });
34
+
35
+ export const applicationRuntimeConfiguration = runtimeConfigurationFingerprint(
36
+ applicationRuntime,
37
+ process.env,
38
+ );
39
+
40
+ export function getApplicationDatabaseConfig(): SmrtClassOptions['db'] {
41
+ if (applicationRuntime.profile === 'local') {
42
+ const paths = resolveLocalRuntimePaths({
43
+ appId,
44
+ dataDirectory: process.env.SMRT_DATA_DIR,
45
+ sourceRoot: process.cwd(),
46
+ });
47
+ return { type: 'sqlite', url: paths.database };
48
+ }
49
+ const databaseUrl = process.env.DATABASE_URL;
50
+ if (!databaseUrl) {
51
+ throw new Error(`${applicationRuntime.profile} requires DATABASE_URL.`);
52
+ }
53
+ return { type: 'postgres', url: databaseUrl };
54
+ }
55
+
56
+ let localRuntimePromise: Promise<LocalApplicationRuntime> | undefined;
57
+ let localWriterLease: { release(): void } | undefined;
58
+ let deployedRuntimePromise: ReturnType<
59
+ typeof initializeDeployedApplicationRuntime
60
+ > | undefined;
61
+
62
+ /** Fail-closed startup gate for every deployed web process. */
63
+ export async function ensureApplicationRuntimeReady(): Promise<void> {
64
+ if (applicationRuntime.profile === 'local') {
65
+ await getLocalApplicationRuntime();
66
+ return;
67
+ }
68
+ const databaseUrl = process.env.DATABASE_URL;
69
+ if (!databaseUrl) {
70
+ throw new Error(`${applicationRuntime.profile} requires DATABASE_URL.`);
71
+ }
72
+ const authenticationProvider =
73
+ applicationRuntime.providers.authentication.provider;
74
+ if (authenticationProvider === 'owner-bootstrap') {
75
+ throw new Error('Deployed profiles require public authentication.');
76
+ }
77
+ deployedRuntimePromise ??= initializeDeployedApplicationRuntime({
78
+ profile: applicationRuntime.profile,
79
+ providers: {
80
+ database: applicationRuntime.providers.database,
81
+ authentication: applicationRuntime.providers.authentication,
82
+ tenancy: applicationRuntime.providers.tenancy,
83
+ assets: applicationRuntime.providers.assets,
84
+ secrets: applicationRuntime.providers.secrets,
85
+ jobs: applicationRuntime.providers.jobs,
86
+ network: applicationRuntime.providers.network,
87
+ },
88
+ database: {
89
+ engine: 'postgres',
90
+ connect: () => getDatabase({ type: 'postgres', url: databaseUrl }),
91
+ close: async (db) => db.close?.(),
92
+ },
93
+ authentication: {
94
+ provider: authenticationProvider,
95
+ readiness: createProviderReadinessProbe('authentication', {
96
+ profile: applicationRuntime.profile,
97
+ provider: authenticationProvider,
98
+ }),
99
+ },
100
+ assets: {
101
+ provider: applicationRuntime.providers.assets.provider,
102
+ readiness: createProviderReadinessProbe('assets', {
103
+ profile: applicationRuntime.profile,
104
+ provider: applicationRuntime.providers.assets.provider,
105
+ }),
106
+ },
107
+ secrets: {
108
+ provider: applicationRuntime.providers.secrets.provider,
109
+ readiness: createProviderReadinessProbe('secrets', {
110
+ profile: applicationRuntime.profile,
111
+ provider: applicationRuntime.providers.secrets.provider,
112
+ }),
113
+ },
114
+ });
115
+ await deployedRuntimePromise;
116
+ }
117
+
118
+ /** Local onboarding runtime. Deployed authentication belongs to its provider. */
119
+ export function getLocalApplicationRuntime(): Promise<LocalApplicationRuntime> {
120
+ if (applicationRuntime.profile !== 'local') {
121
+ throw new Error('Owner bootstrap is available only in the local profile.');
122
+ }
123
+ const bindHost =
124
+ process.env.HOST ||
125
+ (process.env.NODE_ENV === 'development' ? '127.0.0.1' : null);
126
+ if (!bindHost) {
127
+ throw new Error(
128
+ 'Local production startup requires an explicit loopback HOST; use pnpm app:start.',
129
+ );
130
+ }
131
+ localWriterLease ??= acquireWriterLease(
132
+ prepareApplicationStateRoot({
133
+ appId,
134
+ dataDirectory: process.env.SMRT_DATA_DIR,
135
+ sourceRoot: process.cwd(),
136
+ }),
137
+ { operationInstance: process.env.SMRT_OPERATION_INSTANCE },
138
+ );
139
+ localRuntimePromise ??= initializeLocalApplicationRuntime({
140
+ appId,
141
+ dataDirectory: process.env.SMRT_DATA_DIR,
142
+ sourceRoot: process.cwd(),
143
+ bindHost,
144
+ providers: {
145
+ database: applicationRuntime.providers.database,
146
+ authentication: applicationRuntime.providers.authentication,
147
+ tenancy: applicationRuntime.providers.tenancy,
148
+ assets: applicationRuntime.providers.assets,
149
+ secrets: applicationRuntime.providers.secrets,
150
+ jobs: applicationRuntime.providers.jobs,
151
+ network: applicationRuntime.providers.network,
152
+ },
153
+ backgroundJobs: process.env.SMRT_BACKGROUND_JOBS === 'true',
154
+ })
155
+ .then(({ runtime }) => runtime)
156
+ .catch((error) => {
157
+ localWriterLease?.release();
158
+ localWriterLease = undefined;
159
+ localRuntimePromise = undefined;
160
+ throw error;
161
+ });
162
+ return localRuntimePromise;
163
+ }
164
+
165
+ export interface ApplicationRuntimeDiagnosticsOptions {
166
+ readonly toolNames: readonly string[];
167
+ readonly observedAt: Date;
168
+ /** Application-owned schema verification seam; runtime liveness is not proof. */
169
+ readonly schemaStatus?: 'not-ready' | 'ready' | 'unknown';
170
+ /** Application-owned migration tracker seam; database liveness is not proof. */
171
+ readonly migrationStatus?: 'current' | 'failed' | 'pending' | 'unknown';
172
+ /** Bounded worker seam supplied by the deployment's lease/heartbeat adapter. */
173
+ readonly workerHeartbeatAt?: Date | string | null;
174
+ /** Stable code/timestamp pairs only; raw errors never cross this seam. */
175
+ readonly recentErrors?: readonly {
176
+ readonly code?: unknown;
177
+ readonly at?: unknown;
178
+ }[];
179
+ }
180
+
181
+ /**
182
+ * Read the private runtime only after the route has authenticated and
183
+ * authorized its caller, then immediately project onto the public allowlist.
184
+ */
185
+ export async function readApplicationRuntimeDiagnostics(
186
+ options: ApplicationRuntimeDiagnosticsOptions,
187
+ ): Promise<RuntimeDiagnostics> {
188
+ await ensureApplicationRuntimeReady();
189
+
190
+ if (applicationRuntime.profile === 'local') {
191
+ const runtime = await getLocalApplicationRuntime();
192
+ const diagnostics = await runtime.diagnostics();
193
+ return projectRuntimeDiagnostics({
194
+ profile: 'local',
195
+ health: 'healthy',
196
+ schema: {
197
+ status: options.schemaStatus ?? 'unknown',
198
+ migrations: options.migrationStatus ?? 'unknown',
199
+ },
200
+ capabilities: {
201
+ 'asset-storage': 'available',
202
+ authentication: 'available',
203
+ 'background-jobs': diagnostics.jobs.backgroundEnabled
204
+ ? 'available'
205
+ : 'disabled',
206
+ database: 'available',
207
+ 'paid-capabilities': diagnostics.paidCapabilitiesEnabled
208
+ ? 'available'
209
+ : 'disabled',
210
+ 'secret-storage': 'available',
211
+ },
212
+ toolNames: options.toolNames,
213
+ worker: {
214
+ topology: diagnostics.jobs.topology,
215
+ required: diagnostics.jobs.backgroundEnabled,
216
+ heartbeatAt: options.workerHeartbeatAt,
217
+ },
218
+ recentErrors: options.recentErrors,
219
+ observedAt: options.observedAt,
220
+ });
221
+ }
222
+
223
+ const runtime = await deployedRuntimePromise;
224
+ if (!runtime) throw new Error('deployed_runtime_unavailable');
225
+ const readiness = await runtime.readiness();
226
+ const componentStatus = (
227
+ component: keyof typeof readiness.components,
228
+ ): 'available' | 'unavailable' =>
229
+ readiness.components[component].status === 'ready'
230
+ ? 'available'
231
+ : 'unavailable';
232
+ const input: RuntimeDiagnosticsProjectionInput = {
233
+ profile: applicationRuntime.profile,
234
+ health:
235
+ runtime.health().status === 'healthy'
236
+ ? readiness.status === 'ready'
237
+ ? 'healthy'
238
+ : 'degraded'
239
+ : 'stopped',
240
+ schema: {
241
+ status: options.schemaStatus ?? 'unknown',
242
+ migrations: options.migrationStatus ?? 'unknown',
243
+ },
244
+ capabilities: {
245
+ 'asset-storage': componentStatus('assets'),
246
+ authentication: componentStatus('authentication'),
247
+ 'background-jobs': 'available',
248
+ database: componentStatus('database'),
249
+ 'paid-capabilities': 'unknown',
250
+ 'secret-storage': componentStatus('secrets'),
251
+ },
252
+ toolNames: options.toolNames,
253
+ worker: {
254
+ topology: runtime.resolvedRuntime.providers.jobs.topology,
255
+ required: true,
256
+ heartbeatAt: options.workerHeartbeatAt,
257
+ },
258
+ recentErrors: options.recentErrors,
259
+ observedAt: options.observedAt,
260
+ };
261
+ return projectRuntimeDiagnostics(input);
262
+ }
@@ -0,0 +1,114 @@
1
+ import type { RuntimeDiagnostics } from '@happyvertical/smrt-app-runtime';
2
+ import type { Role } from '@happyvertical/smrt-users';
3
+ import { webMcpToolDefinitions } from '@happyvertical/smrt-virt-web';
4
+ import { json } from '@sveltejs/kit';
5
+
6
+ import { getCollection } from './smrt.js';
7
+ import { readApplicationRuntimeDiagnostics } from './application-runtime.js';
8
+ import { RUNTIME_DIAGNOSTICS_WEBMCP_TOOL_NAME } from '../runtime-diagnostics-webmcp.js';
9
+
10
+ export const RUNTIME_DIAGNOSTICS_READ_PERMISSION = 'runtime_diagnostics.read';
11
+
12
+ interface DiagnosticsLocals {
13
+ readonly user?: { readonly id?: unknown } | null;
14
+ readonly membership?: {
15
+ readonly userId?: unknown;
16
+ readonly tenantId?: unknown;
17
+ readonly roleId?: unknown;
18
+ isActive?: () => boolean;
19
+ } | null;
20
+ readonly permissions?: readonly string[];
21
+ readonly tenantId?: unknown;
22
+ readonly sessionId?: unknown;
23
+ }
24
+
25
+ interface RuntimeDiagnosticsHandlerOptions {
26
+ readonly resolveRoleSlug: (roleId: string) => Promise<string | null>;
27
+ readonly readDiagnostics: () => Promise<RuntimeDiagnostics>;
28
+ }
29
+
30
+ /** Testable route boundary: authorization always completes before projection. */
31
+ export function createRuntimeDiagnosticsGet(
32
+ options: RuntimeDiagnosticsHandlerOptions,
33
+ ) {
34
+ return async ({ locals }: { locals: DiagnosticsLocals }) => {
35
+ const principal = authenticatedPrincipal(locals);
36
+ if (!principal) {
37
+ return stableError(401, 'authentication_required');
38
+ }
39
+
40
+ let authorized =
41
+ locals.permissions?.includes(RUNTIME_DIAGNOSTICS_READ_PERMISSION) === true;
42
+ if (!authorized) {
43
+ try {
44
+ authorized = (await options.resolveRoleSlug(principal.roleId)) === 'owner';
45
+ } catch {
46
+ authorized = false;
47
+ }
48
+ }
49
+ if (!authorized) return stableError(403, 'authorization_denied');
50
+
51
+ try {
52
+ return json(await options.readDiagnostics());
53
+ } catch {
54
+ return stableError(503, 'diagnostics_unavailable');
55
+ }
56
+ };
57
+ }
58
+
59
+ export const runtimeDiagnosticsGet = createRuntimeDiagnosticsGet({
60
+ async resolveRoleSlug(roleId) {
61
+ const roles = await getCollection<Role>('Role');
62
+ return (await roles.findById(roleId))?.slug ?? null;
63
+ },
64
+ readDiagnostics: () =>
65
+ readApplicationRuntimeDiagnostics({
66
+ toolNames: [
67
+ ...webMcpToolDefinitions.map((definition) => definition.name),
68
+ RUNTIME_DIAGNOSTICS_WEBMCP_TOOL_NAME,
69
+ ],
70
+ observedAt: new Date(),
71
+ // Install application migration/schema verifiers here. Process/database
72
+ // liveness alone intentionally leaves both values `unknown`.
73
+ schemaStatus: 'unknown',
74
+ migrationStatus: 'unknown',
75
+ // External deployments install their lease-backed heartbeat adapter here.
76
+ // Missing data remains `unknown`; web-process liveness is never substituted.
77
+ workerHeartbeatAt: null,
78
+ recentErrors: [],
79
+ }),
80
+ });
81
+
82
+ function authenticatedPrincipal(locals: DiagnosticsLocals): {
83
+ roleId: string;
84
+ } | null {
85
+ const userId = locals.user?.id;
86
+ const tenantId = locals.tenantId;
87
+ const sessionId = locals.sessionId;
88
+ const membership = locals.membership;
89
+ if (
90
+ typeof userId !== 'string' ||
91
+ !userId ||
92
+ typeof tenantId !== 'string' ||
93
+ !tenantId ||
94
+ typeof sessionId !== 'string' ||
95
+ !sessionId ||
96
+ !membership ||
97
+ membership.userId !== userId ||
98
+ membership.tenantId !== tenantId ||
99
+ typeof membership.roleId !== 'string' ||
100
+ !membership.roleId
101
+ ) {
102
+ return null;
103
+ }
104
+ try {
105
+ if (membership.isActive?.() !== true) return null;
106
+ } catch {
107
+ return null;
108
+ }
109
+ return { roleId: membership.roleId };
110
+ }
111
+
112
+ function stableError(status: number, code: string) {
113
+ return json({ schemaVersion: 1, error: { code } }, { status });
114
+ }
@@ -16,6 +16,8 @@ import {
16
16
  import { loadManifestFromPathSync } from '@happyvertical/smrt-core/manifest';
17
17
  import { getRequestScopedDatabase } from '@happyvertical/smrt-users';
18
18
 
19
+ import { getApplicationDatabaseConfig } from './application-runtime.js';
20
+
19
21
  // Import all SMRT objects to register them.
20
22
  import '../objects/index.js';
21
23
 
@@ -25,6 +27,7 @@ import '../objects/index.js';
25
27
  // (it is gitignored, like the generated API routes); guard the import so a
26
28
  // fresh checkout that has not run the plugin yet still boots.
27
29
  try {
30
+ // @ts-ignore generated by smrtPlugin before a consumer build starts
28
31
  await import('./smrt-register.js');
29
32
  } catch (error) {
30
33
  // Not generated yet — the first `vite dev` / `vite build` creates it. Do
@@ -66,10 +69,7 @@ const objectOverrides: Record<string, Partial<SmrtClassOptions>> = {
66
69
  */
67
70
  function getDefaultConfig(): SmrtClassOptions {
68
71
  return {
69
- db: {
70
- url: process.env.DATABASE_URL || './app.db',
71
- type: (process.env.DATABASE_TYPE as 'sqlite' | 'postgres') || 'sqlite',
72
- },
72
+ db: getApplicationDatabaseConfig(),
73
73
  };
74
74
  }
75
75
 
@@ -10,6 +10,7 @@
10
10
  } from '@happyvertical/smrt-svelte/workspace';
11
11
  import { ThemeProvider } from '@happyvertical/smrt-ui/themes';
12
12
  import '@happyvertical/smrt-ui/themes/styles/fonts.css';
13
+ import RuntimeDiagnosticsWebMcp from '$lib/RuntimeDiagnosticsWebMcp.svelte';
13
14
  import type { LayoutProps } from './$types';
14
15
 
15
16
  let { data, children }: LayoutProps = $props();
@@ -48,6 +49,7 @@
48
49
  </script>
49
50
 
50
51
  <Provider {webmcp}>
52
+ <RuntimeDiagnosticsWebMcp enabled={data.session.authenticated} />
51
53
  <ThemeProvider preset="smrt" colorScheme="system" persist={true}>
52
54
  <AdminShell
53
55
  title="s-m-r-t app"
@@ -0,0 +1,5 @@
1
+ import type { RequestHandler } from './$types';
2
+
3
+ import { runtimeDiagnosticsGet } from '$lib/server/runtime-diagnostics';
4
+
5
+ export const GET: RequestHandler = runtimeDiagnosticsGet;
@@ -0,0 +1,30 @@
1
+ import { json } from '@sveltejs/kit';
2
+ import type { RequestHandler } from './$types';
3
+ import { resolveApplicationId } from '../../../../../scripts/smrt-runtime-identity.mjs';
4
+ import {
5
+ applicationRuntime,
6
+ applicationRuntimeConfiguration,
7
+ } from '$lib/server/application-runtime';
8
+
9
+ const applicationId = resolveApplicationId({
10
+ sourceRoot: process.cwd(),
11
+ explicitId: process.env.SMRT_APP_ID,
12
+ });
13
+
14
+ export const GET: RequestHandler = async () => {
15
+ const publicHealth = {
16
+ schemaVersion: 1,
17
+ status: 'ready',
18
+ profile: applicationRuntime.profile,
19
+ };
20
+ return json(
21
+ applicationRuntime.profile === 'local'
22
+ ? {
23
+ ...publicHealth,
24
+ application: applicationId,
25
+ instance: process.env.SMRT_PROCESS_INSTANCE || null,
26
+ configuration: applicationRuntimeConfiguration,
27
+ }
28
+ : publicHealth,
29
+ );
30
+ };
@@ -0,0 +1,96 @@
1
+ import { rmSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { fail, redirect } from '@sveltejs/kit';
4
+ import type { Actions, PageServerLoad } from './$types';
5
+
6
+ import {
7
+ applicationRuntime,
8
+ getLocalApplicationRuntime,
9
+ } from '$lib/server/application-runtime';
10
+ import {
11
+ resolveApplicationId,
12
+ resolveApplicationStateRoot,
13
+ } from '../../../scripts/smrt-runtime-identity.mjs';
14
+
15
+ export const load: PageServerLoad = async ({ url, locals }) => {
16
+ if (locals.user) throw redirect(303, '/');
17
+ if (applicationRuntime.profile !== 'local') {
18
+ return { available: false, token: '' };
19
+ }
20
+ const runtime = await getLocalApplicationRuntime();
21
+ const diagnostics = await runtime.diagnostics();
22
+ return {
23
+ available: diagnostics.bootstrap.status !== 'claimed',
24
+ token: url.searchParams.get('token') || '',
25
+ };
26
+ };
27
+
28
+ export const actions: Actions = {
29
+ default: async (event) => {
30
+ if (applicationRuntime.profile !== 'local') {
31
+ return fail(404, { message: 'Local owner setup is disabled.' });
32
+ }
33
+ const form = await event.request.formData();
34
+ const token = String(form.get('token') || '');
35
+ const name = String(form.get('name') || '');
36
+ const email = String(form.get('email') || '');
37
+ if (!token || !name.trim() || !email.includes('@')) {
38
+ return fail(400, { message: 'Name, email, and a valid setup token are required.' });
39
+ }
40
+ const runtime = await getLocalApplicationRuntime();
41
+ let result;
42
+ try {
43
+ result = await runtime.claimOwner({
44
+ token,
45
+ name,
46
+ email,
47
+ userAgent: event.request.headers.get('user-agent') || undefined,
48
+ ipAddress: event.getClientAddress(),
49
+ });
50
+ } catch {
51
+ return fail(400, {
52
+ message:
53
+ 'The setup invitation is invalid, expired, or already used. Run pnpm app:stop, pnpm app:recover, pnpm app:start, then pnpm app:open.',
54
+ });
55
+ }
56
+ event.cookies.set('sid', result.sessionId, {
57
+ path: '/',
58
+ httpOnly: true,
59
+ secure: event.url.protocol === 'https:',
60
+ sameSite: 'lax',
61
+ maxAge: 7 * 24 * 60 * 60,
62
+ });
63
+ try {
64
+ const appId = resolveApplicationId({
65
+ sourceRoot: process.cwd(),
66
+ explicitId: process.env.SMRT_APP_ID,
67
+ });
68
+ rmSync(
69
+ join(
70
+ resolveApplicationStateRoot({
71
+ appId,
72
+ dataDirectory: process.env.SMRT_DATA_DIR,
73
+ sourceRoot: process.cwd(),
74
+ }),
75
+ 'onboarding.json',
76
+ ),
77
+ { force: true },
78
+ );
79
+ rmSync(
80
+ join(
81
+ resolveApplicationStateRoot({
82
+ appId,
83
+ dataDirectory: process.env.SMRT_DATA_DIR,
84
+ sourceRoot: process.cwd(),
85
+ }),
86
+ 'onboarding-launch.html',
87
+ ),
88
+ { force: true },
89
+ );
90
+ } catch {
91
+ // The owner and session are authoritative; stale mode-0600 handoff files
92
+ // only contain a token that now fails closed as already claimed.
93
+ }
94
+ throw redirect(303, '/');
95
+ },
96
+ };
@@ -0,0 +1,31 @@
1
+ <script lang="ts">
2
+ import type { PageProps } from './$types';
3
+
4
+ let { data, form }: PageProps = $props();
5
+ </script>
6
+
7
+ <svelte:head><title>Set up your local application</title></svelte:head>
8
+
9
+ <main>
10
+ <h1>Set up your local application</h1>
11
+ {#if !data.available}
12
+ <p>Local owner setup is unavailable or has already been completed.</p>
13
+ {:else}
14
+ <p>Create the real local owner account. This invitation works once and stays on this device.</p>
15
+ {#if form?.message}<p role="alert">{form.message}</p>{/if}
16
+ <form method="POST">
17
+ <input type="hidden" name="token" value={data.token} />
18
+ <label>Name <input name="name" autocomplete="name" required /></label>
19
+ <label>Email <input name="email" type="email" autocomplete="email" required /></label>
20
+ <button type="submit">Create owner</button>
21
+ </form>
22
+ {/if}
23
+ </main>
24
+
25
+ <style>
26
+ main { max-width: 36rem; margin: 4rem auto; padding: 1.5rem; }
27
+ form { display: grid; gap: 1rem; }
28
+ label { display: grid; gap: 0.35rem; }
29
+ input { padding: 0.65rem; }
30
+ button { width: fit-content; padding: 0.65rem 1rem; }
31
+ </style>