@happyvertical/smrt-template-sveltekit 0.38.26 → 0.39.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.
@@ -1,29 +1,38 @@
1
1
  {
2
2
  "name": "smrt-sveltekit-app",
3
3
  "version": "0.0.1",
4
+ "private": true,
4
5
  "type": "module",
6
+ "packageManager": "pnpm@10.34.4",
7
+ "engines": {
8
+ "node": ">=24.18.0",
9
+ "pnpm": "10.34.4"
10
+ },
5
11
  "scripts": {
6
12
  "dev": "vite dev",
7
13
  "build": "vite build",
8
14
  "preview": "vite preview",
9
15
  "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
10
- "typecheck": "svelte-kit sync && tsc --noEmit && svelte-check --tsconfig ./tsconfig.json",
11
- "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
16
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
17
+ "db:migrate": "vite build && smrt db:migrate"
12
18
  },
13
19
  "devDependencies": {
14
20
  "@sveltejs/adapter-auto": "^7.0.1",
15
- "@sveltejs/kit": "^2.69.1",
16
- "@sveltejs/vite-plugin-svelte": "^7.1.2",
21
+ "@sveltejs/kit": "^2.69.2",
22
+ "@sveltejs/vite-plugin-svelte": "^7.2.0",
23
+ "@types/node": "^24.13.3",
17
24
  "svelte": "^5.56.4",
18
- "svelte-check": "^4.7.1",
19
- "typescript": "^5.9.3",
20
- "vite": "^8.1.3"
25
+ "svelte-check": "^4.7.2",
26
+ "typescript": "^6.0.3",
27
+ "vite": "^8.1.4",
28
+ "@happyvertical/smrt-cli": "^0.39.0"
21
29
  },
22
30
  "dependencies": {
23
- "@happyvertical/smrt-core": "^0.38.26",
24
- "@happyvertical/smrt-svelte": "^0.38.26",
25
- "@happyvertical/smrt-tenancy": "^0.38.26",
26
- "@happyvertical/smrt-ui": "^0.38.26",
27
- "@happyvertical/smrt-users": "^0.38.26"
31
+ "@happyvertical/smrt-core": "^0.39.0",
32
+ "@happyvertical/smrt-profiles": "^0.39.0",
33
+ "@happyvertical/smrt-svelte": "^0.39.0",
34
+ "@happyvertical/smrt-tenancy": "^0.39.0",
35
+ "@happyvertical/smrt-ui": "^0.39.0",
36
+ "@happyvertical/smrt-users": "^0.39.0"
28
37
  }
29
38
  }
@@ -6,14 +6,6 @@
6
6
  */
7
7
 
8
8
  export default {
9
- // Global SMRT settings
10
- smrt: {
11
- logLevel: 'info',
12
- schemaMigration: {
13
- strategy: 'auto-add',
14
- },
15
- },
16
-
17
9
  // Agent/developer knowledge artifacts are generated by smrtPlugin().
18
10
  // HTTP API routes stay off unless explicitly enabled for a dev/admin-only workflow.
19
11
  knowledge: {
@@ -33,17 +25,10 @@ export default {
33
25
  cli: {
34
26
  database: {
35
27
  type: 'sqlite',
36
- url: process.env.DATABASE_URL || './data/app.db',
28
+ url: process.env.DATABASE_URL || './app.db',
37
29
  },
38
30
  verbose: false,
39
31
  },
40
32
 
41
- // AI configuration (optional)
42
- ai: process.env.OPENAI_API_KEY
43
- ? {
44
- provider: 'openai',
45
- apiKey: process.env.OPENAI_API_KEY,
46
- }
47
- : undefined,
48
33
  },
49
34
  };
@@ -8,11 +8,12 @@ declare global {
8
8
  // interface Error {}
9
9
  interface Locals extends SessionLocals {
10
10
  /**
11
- * Tenant context object set by the smrt-tenancy SvelteKit handle.
12
- * `event.locals.tenantId` (from SessionLocals) is the canonical id
13
- * string; this object carries the full context (permissions,
14
- * superAdminBypass, etc.) when needed.
11
+ * URL-selected tenant candidate. Never use this as authorization; the
12
+ * session-authorized tenant remains `tenantId` from SessionLocals.
15
13
  */
14
+ selectedTenantId: string | null;
15
+ selectedTenantSlug: string | null;
16
+ /** Full context for the authorized session tenant, when present. */
16
17
  tenantContext?: import('@happyvertical/smrt-tenancy').MinimalTenantContext;
17
18
  }
18
19
  // interface PageData {}
@@ -2,7 +2,6 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
- <link rel="icon" href="%sveltekit.assets%/favicon.png" />
6
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
7
6
  %sveltekit.head%
8
7
  </head>
@@ -1,137 +1,53 @@
1
1
  /**
2
- * SvelteKit server hooks.
2
+ * Request foundation, in order:
3
3
  *
4
- * Pre-wired with:
5
- * 1. `enableTenancy()` — registers the @happyvertical/smrt-tenancy
6
- * interceptor with `GlobalInterceptors` at priority 100, so any class
7
- * decorated with `@TenantScoped` is auto-filtered by the tenant in
8
- * AsyncLocalStorage. Idempotent — safe to call on every module load.
9
- * 2. A subdomain-based tenant resolver (`tenancyHandle`) — if the URL has
10
- * a leading subdomain (e.g. `acme.demo.local`), the request runs inside
11
- * `withTenant({ tenantId: 'acme' })`.
12
- * 3. `createSessionHandler({ enterTenantContext: true })` from
13
- * @happyvertical/smrt-users/sveltekit — populates
14
- * `event.locals.{user, permissions, tenantId, sessionId}` and pushes
15
- * the session's tenant into the AsyncLocalStorage context.
16
- * 4. `reconcileTenantLocals` — final reconciliation step. The session
17
- * handler initializes `event.locals.tenantId = null` before reading
18
- * the session, so for unauthenticated public requests it clobbers the
19
- * subdomain value set by step 2. This handle restores the subdomain
20
- * tenant on `event.locals.tenantId` when no session tenant set it,
21
- * so layout/page/action code reads a consistent value regardless of
22
- * auth state.
4
+ * 1. Resolve a URL tenant candidate into `locals.selectedTenant*`. This is
5
+ * selection only and does not enter AsyncLocalStorage tenant context.
6
+ * 2. Load the signed session. `enterTenantContext: true` establishes the
7
+ * authorized session tenant and its permission set for downstream code.
8
+ * 3. Publish the authorized context on locals when it matches the session.
23
9
  *
24
- * To swap the resolution strategy, edit `src/lib/server/tenancy.ts`.
10
+ * This ordering prevents a spoofed header or hostname from becoming query
11
+ * authority. Membership-gated tenant switching belongs in an explicit action
12
+ * using `switchSessionTenant()` from `@happyvertical/smrt-users/sveltekit`.
25
13
  */
26
14
 
27
- import { sequence } from '@sveltejs/kit/hooks';
28
- import type { Handle } from '@sveltejs/kit';
29
-
15
+ import { getCurrentTenant, enableTenancy } from '@happyvertical/smrt-tenancy';
30
16
  import { createSessionHandler } from '@happyvertical/smrt-users/sveltekit';
31
- import {
32
- createSvelteKitHandle,
33
- enableTenancy,
34
- getCurrentTenant,
35
- } from '@happyvertical/smrt-tenancy';
17
+ import type { Handle } from '@sveltejs/kit';
18
+ import { sequence } from '@sveltejs/kit/hooks';
36
19
 
37
- import { resolveTenant } from '$lib/server/tenancy';
38
20
  import { getSmrtConfig } from '$lib/server/smrt';
21
+ import { resolveTenant } from '$lib/server/tenancy';
39
22
 
40
- // Register the tenancy interceptor globally. Idempotent — `enableTenancy()`
41
- // guards against duplicate registration internally.
42
23
  enableTenancy();
43
24
 
44
- // Both upstream factories (`createSvelteKitHandle` from smrt-tenancy and
45
- // `createSessionHandler` from smrt-users/sveltekit) declare a structural
46
- // event type with `locals: Record<string, unknown>` to avoid taking a
47
- // hard dependency on `@sveltejs/kit`. SvelteKit's own `Handle` expects
48
- // `event.locals: App.Locals` — a strict named interface without an
49
- // index signature — so the two shapes aren't directly assignable under
50
- // strict typechecking. The casts below adapt the upstream return types
51
- // to SvelteKit's `Handle` once. Runtime behaviour is unchanged; the
52
- // types still flow through `Handle` to the `sequence(...)` composition.
53
-
54
- /**
55
- * Subdomain → tenantId handle. Runs BEFORE the session handler so generated
56
- * routes have a tenant in context even for unauthenticated public requests
57
- * (e.g. tenant-scoped catalog endpoints). If a session also carries a
58
- * tenant, the session handler's inner `withTenant()` will override.
59
- */
60
- const tenancyHandle = createSvelteKitHandle({
61
- resolveTenantId: async (event) => {
62
- // The createSvelteKitHandle adapter passes a structural event that is
63
- // directly assignable to the resolver's `TenantResolverEvent` shape
64
- // (`url` + `request.headers`), so no cast is needed.
65
- const result = await resolveTenant(event);
66
- return result.tenantId;
67
- },
68
- }) as unknown as Handle;
25
+ const tenantSelectionHandle: Handle = async ({ event, resolve }) => {
26
+ const selection = await resolveTenant(event);
27
+ event.locals.selectedTenantId = selection.tenantId;
28
+ event.locals.selectedTenantSlug = selection.tenantSlug;
29
+ return resolve(event);
30
+ };
69
31
 
70
- /**
71
- * Session handle from @happyvertical/smrt-users. `enterTenantContext: true`
72
- * makes it call `enterTenantContext()` after loading the session, so
73
- * downstream code sees the *session's* tenant id when one is present.
74
- */
75
32
  const sessionHandle = createSessionHandler({
76
33
  ...getSmrtConfig('Session'),
77
34
  enterTenantContext: true,
78
35
  }) as unknown as Handle;
79
36
 
80
- /**
81
- * Final reconciliation: read the active AsyncLocalStorage tenant (already
82
- * set correctly by tenancyHandle + sessionHandle, with session winning
83
- * over subdomain when both are present) and align BOTH `event.locals.tenantId`
84
- * AND `event.locals.tenantContext` to it.
85
- *
86
- * Two failure modes this guards against:
87
- *
88
- * 1. **Public requests with a subdomain.** `createSessionHandler` resets
89
- * `event.locals.tenantId` to `null` before checking for a session
90
- * cookie. With no session, the session handler returns without
91
- * repopulating it, so the subdomain-resolved tenant goes missing
92
- * from layouts/actions even though the ALS context (set by
93
- * `tenancyHandle`) still carries it.
94
- * 2. **Authenticated requests where the session's tenant differs from
95
- * the subdomain.** The session handler calls `withTenant(sessionCtx, ...)`
96
- * — switching the ALS context to the session tenant — and updates
97
- * `event.locals.tenantId` to match. But it does NOT touch
98
- * `event.locals.tenantContext`, which is still the subdomain context
99
- * previously written by `tenancyHandle`. Layouts/actions reading
100
- * `tenantContext` would then see stale permissions/superAdminBypass
101
- * that don't match the tenant their queries are scoped to.
102
- *
103
- * Reading the live ALS context here and writing it back to BOTH
104
- * `locals.tenantId` and `locals.tenantContext` keeps the three vantage
105
- * points (locals.tenantId, locals.tenantContext, ALS-scoped queries) in
106
- * sync regardless of auth state.
107
- *
108
- * **ALS-scope dependency:** this handle reads `getCurrentTenant()` from
109
- * AsyncLocalStorage, which only works because both upstream handles
110
- * call `resolve(event)` *inside* their own ALS scope:
111
- * - `createSvelteKitHandle` (smrt-tenancy) wraps in `withTenant(ctx, () => resolve(event))`.
112
- * - `createSessionHandler` (smrt-users) wraps in `withSessionPermissionContext`,
113
- * which internally calls `withTenant(sessionCtx, fn)` when the session
114
- * has a tenantId.
115
- * If a future refactor moved either handle's `resolve()` call outside
116
- * its ALS scope, `getCurrentTenant()` here would return `undefined` and
117
- * the reconciliation would silently no-op. We deliberately do NOT fall
118
- * back to `event.locals.tenantId` here — that would re-introduce the
119
- * stale-tenantContext bug from the subdomain handler. If the upstream
120
- * scoping ever changes, this handle should be updated to read from a
121
- * dedicated cross-handler carrier (e.g. `event.locals._tenantSource`)
122
- * rather than guessing.
123
- */
124
- const reconcileTenantLocals: Handle = async ({ event, resolve }) => {
37
+ const authorizedTenantLocalsHandle: Handle = async ({ event, resolve }) => {
125
38
  const activeContext = getCurrentTenant();
126
- if (activeContext) {
127
- event.locals.tenantId = activeContext.tenantId;
39
+ if (
40
+ event.locals.user &&
41
+ event.locals.tenantId &&
42
+ activeContext?.tenantId === event.locals.tenantId
43
+ ) {
128
44
  event.locals.tenantContext = activeContext;
129
45
  }
130
46
  return resolve(event);
131
47
  };
132
48
 
133
49
  export const handle: Handle = sequence(
134
- tenancyHandle,
50
+ tenantSelectionHandle,
135
51
  sessionHandle,
136
- reconcileTenantLocals,
52
+ authorizedTenantLocalsHandle,
137
53
  );
@@ -1,19 +1,32 @@
1
1
  /**
2
- * Example SMRT Object
2
+ * Example s-m-r-t object.
3
3
  *
4
4
  * This is a sample SMRT object to demonstrate the pattern.
5
5
  * Rename or replace this with your own objects.
6
6
  */
7
7
 
8
- import { SmrtObject, smrt } from '@happyvertical/smrt-core';
8
+ import {
9
+ ObjectRegistry,
10
+ SmrtCollection,
11
+ SmrtObject,
12
+ smrt,
13
+ } from '@happyvertical/smrt-core';
14
+ import { TenantScoped, tenantId } from '@happyvertical/smrt-tenancy';
9
15
 
10
16
  @smrt({
11
17
  api: {
12
- include: ['list', 'get', 'create', 'update', 'delete', 'summarize'],
18
+ include: ['list', 'get', 'create', 'update', 'delete'],
19
+ writable: ['title', 'description', 'status'],
13
20
  },
14
- cli: { include: ['list', 'get', 'summarize'] },
21
+ cli: { include: ['list', 'get', 'create', 'update', 'delete'] },
22
+ mcp: { include: ['list', 'get', 'create', 'update', 'delete'] },
15
23
  })
24
+ @TenantScoped({ mode: 'optional' })
16
25
  export class Item extends SmrtObject {
26
+ /** Null rows are global; tenant rows are isolated automatically. */
27
+ @tenantId({ nullable: true })
28
+ tenantId: string | null = null;
29
+
17
30
  /** Item title */
18
31
  title: string = '';
19
32
 
@@ -22,23 +35,11 @@ export class Item extends SmrtObject {
22
35
 
23
36
  /** Status of the item */
24
37
  status: string = 'draft';
38
+ }
25
39
 
26
- /** When the item was published */
27
- publishedAt: Date | null = null;
28
-
29
- /**
30
- * AI-powered summarization
31
- */
32
- async summarize(): Promise<string> {
33
- return await this.do('Create a brief summary of this item');
34
- }
35
-
36
- /**
37
- * Mark item as published
38
- */
39
- async publish(): Promise<void> {
40
- this.status = 'published';
41
- this.publishedAt = new Date();
42
- await this.save();
43
- }
40
+ /** Explicit collection constructor used by generated CLI/MCP/runtime tools. */
41
+ export class ItemCollection extends SmrtCollection<Item> {
42
+ static readonly _itemClass = Item;
44
43
  }
44
+
45
+ ObjectRegistry.registerCollection('Item', ItemCollection);
@@ -5,4 +5,4 @@
5
5
  * They will be automatically registered and available for CLI/API/MCP.
6
6
  */
7
7
 
8
- export { Item } from './Item.js';
8
+ export { Item, ItemCollection } from './Item.js';
@@ -14,6 +14,7 @@ import {
14
14
  type SmrtObject,
15
15
  } from '@happyvertical/smrt-core';
16
16
  import { loadManifestFromPathSync } from '@happyvertical/smrt-core/manifest';
17
+ import { getRequestScopedDatabase } from '@happyvertical/smrt-users';
17
18
 
18
19
  // Import all SMRT objects to register them.
19
20
  import '../objects/index.js';
@@ -25,8 +26,17 @@ import '../objects/index.js';
25
26
  // fresh checkout that has not run the plugin yet still boots.
26
27
  try {
27
28
  await import('./smrt-register.js');
28
- } catch {
29
- // Not generated yet — the first `vite dev` / `vite build` will create it.
29
+ } catch (error) {
30
+ // Not generated yet — the first `vite dev` / `vite build` creates it. Do
31
+ // not hide a real import/runtime error from an existing generated module.
32
+ const code = (error as NodeJS.ErrnoException).code;
33
+ const message = error instanceof Error ? error.message : '';
34
+ if (
35
+ code !== 'ERR_MODULE_NOT_FOUND' &&
36
+ !message.includes('smrt-register.js')
37
+ ) {
38
+ throw error;
39
+ }
30
40
  }
31
41
 
32
42
  // Hydrate field metadata for this app's own objects from the local manifest
@@ -40,13 +50,6 @@ if (existsSync(localManifestPath)) {
40
50
  loadManifestFromPathSync(localManifestPath);
41
51
  }
42
52
 
43
- declare global {
44
- // eslint-disable-next-line no-var
45
- var __smrtGetRequestScopedDatabase:
46
- | (() => SmrtClassOptions['db'] | undefined)
47
- | undefined;
48
- }
49
-
50
53
  /**
51
54
  * Per-object configuration overrides
52
55
  *
@@ -64,23 +67,12 @@ const objectOverrides: Record<string, Partial<SmrtClassOptions>> = {
64
67
  function getDefaultConfig(): SmrtClassOptions {
65
68
  return {
66
69
  db: {
67
- url: process.env.DATABASE_URL || './data/app.db',
70
+ url: process.env.DATABASE_URL || './app.db',
68
71
  type: (process.env.DATABASE_TYPE as 'sqlite' | 'postgres') || 'sqlite',
69
72
  },
70
- ai: process.env.OPENAI_API_KEY
71
- ? {
72
- type: 'openai',
73
- apiKey: process.env.OPENAI_API_KEY,
74
- }
75
- : undefined,
76
73
  };
77
74
  }
78
75
 
79
- function getRequestScopedDatabase(): SmrtClassOptions['db'] | undefined {
80
- const getter = globalThis.__smrtGetRequestScopedDatabase;
81
- return typeof getter === 'function' ? getter() : undefined;
82
- }
83
-
84
76
  /**
85
77
  * Get configuration for a specific SMRT class
86
78
  */
@@ -102,7 +94,7 @@ export async function getCollection<T extends SmrtObject>(className: string) {
102
94
  const objectOverride = objectOverrides[className];
103
95
  const requestScopedDb = objectOverride?.db
104
96
  ? undefined
105
- : getRequestScopedDatabase();
97
+ : (getRequestScopedDatabase() as SmrtClassOptions['db'] | undefined);
106
98
 
107
99
  return await ObjectRegistry.getCollection<T>(className, {
108
100
  ...config,