@happyvertical/smrt-template-sveltekit 0.30.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.
- package/AGENTS.md +28 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +7 -0
- package/README.md +110 -0
- package/index.js +99 -0
- package/package.json +45 -0
- package/template/.env.example +11 -0
- package/template/AGENTS.md +25 -0
- package/template/CLAUDE.md +1 -0
- package/template/README.md +204 -0
- package/template/package.json +27 -0
- package/template/smrt.config.ts +49 -0
- package/template/src/app.d.ts +24 -0
- package/template/src/app.html +12 -0
- package/template/src/hooks.server.ts +136 -0
- package/template/src/lib/objects/Item.ts +44 -0
- package/template/src/lib/objects/index.ts +8 -0
- package/template/src/lib/server/smrt.ts +81 -0
- package/template/src/lib/server/tenancy.ts +267 -0
- package/template/src/routes/+page.svelte +108 -0
- package/template/svelte.config.js +16 -0
- package/template/tsconfig.json +16 -0
- package/template/vite.config.ts +21 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SvelteKit server hooks.
|
|
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.
|
|
23
|
+
*
|
|
24
|
+
* To swap the resolution strategy, edit `src/lib/server/tenancy.ts`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { sequence } from '@sveltejs/kit/hooks';
|
|
28
|
+
import type { Handle, RequestEvent } from '@sveltejs/kit';
|
|
29
|
+
|
|
30
|
+
import { createSessionHandler } from '@happyvertical/smrt-users/sveltekit';
|
|
31
|
+
import {
|
|
32
|
+
createSvelteKitHandle,
|
|
33
|
+
enableTenancy,
|
|
34
|
+
getCurrentTenant,
|
|
35
|
+
} from '@happyvertical/smrt-tenancy';
|
|
36
|
+
|
|
37
|
+
import { resolveTenant } from '$lib/server/tenancy';
|
|
38
|
+
import { getSmrtConfig } from '$lib/server/smrt';
|
|
39
|
+
|
|
40
|
+
// Register the tenancy interceptor globally. Idempotent — `enableTenancy()`
|
|
41
|
+
// guards against duplicate registration internally.
|
|
42
|
+
enableTenancy();
|
|
43
|
+
|
|
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; our
|
|
63
|
+
// resolver accepts that same shape.
|
|
64
|
+
const result = await resolveTenant(event as RequestEvent);
|
|
65
|
+
return result.tenantId;
|
|
66
|
+
},
|
|
67
|
+
}) as unknown as Handle;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Session handle from @happyvertical/smrt-users. `enterTenantContext: true`
|
|
71
|
+
* makes it call `enterTenantContext()` after loading the session, so
|
|
72
|
+
* downstream code sees the *session's* tenant id when one is present.
|
|
73
|
+
*/
|
|
74
|
+
const sessionHandle = createSessionHandler({
|
|
75
|
+
...getSmrtConfig('Session'),
|
|
76
|
+
enterTenantContext: true,
|
|
77
|
+
}) as unknown as Handle;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Final reconciliation: read the active AsyncLocalStorage tenant (already
|
|
81
|
+
* set correctly by tenancyHandle + sessionHandle, with session winning
|
|
82
|
+
* over subdomain when both are present) and align BOTH `event.locals.tenantId`
|
|
83
|
+
* AND `event.locals.tenantContext` to it.
|
|
84
|
+
*
|
|
85
|
+
* Two failure modes this guards against:
|
|
86
|
+
*
|
|
87
|
+
* 1. **Public requests with a subdomain.** `createSessionHandler` resets
|
|
88
|
+
* `event.locals.tenantId` to `null` before checking for a session
|
|
89
|
+
* cookie. With no session, the session handler returns without
|
|
90
|
+
* repopulating it, so the subdomain-resolved tenant goes missing
|
|
91
|
+
* from layouts/actions even though the ALS context (set by
|
|
92
|
+
* `tenancyHandle`) still carries it.
|
|
93
|
+
* 2. **Authenticated requests where the session's tenant differs from
|
|
94
|
+
* the subdomain.** The session handler calls `withTenant(sessionCtx, ...)`
|
|
95
|
+
* — switching the ALS context to the session tenant — and updates
|
|
96
|
+
* `event.locals.tenantId` to match. But it does NOT touch
|
|
97
|
+
* `event.locals.tenantContext`, which is still the subdomain context
|
|
98
|
+
* previously written by `tenancyHandle`. Layouts/actions reading
|
|
99
|
+
* `tenantContext` would then see stale permissions/superAdminBypass
|
|
100
|
+
* that don't match the tenant their queries are scoped to.
|
|
101
|
+
*
|
|
102
|
+
* Reading the live ALS context here and writing it back to BOTH
|
|
103
|
+
* `locals.tenantId` and `locals.tenantContext` keeps the three vantage
|
|
104
|
+
* points (locals.tenantId, locals.tenantContext, ALS-scoped queries) in
|
|
105
|
+
* sync regardless of auth state.
|
|
106
|
+
*
|
|
107
|
+
* **ALS-scope dependency:** this handle reads `getCurrentTenant()` from
|
|
108
|
+
* AsyncLocalStorage, which only works because both upstream handles
|
|
109
|
+
* call `resolve(event)` *inside* their own ALS scope:
|
|
110
|
+
* - `createSvelteKitHandle` (smrt-tenancy) wraps in `withTenant(ctx, () => resolve(event))`.
|
|
111
|
+
* - `createSessionHandler` (smrt-users) wraps in `withSessionPermissionContext`,
|
|
112
|
+
* which internally calls `withTenant(sessionCtx, fn)` when the session
|
|
113
|
+
* has a tenantId.
|
|
114
|
+
* If a future refactor moved either handle's `resolve()` call outside
|
|
115
|
+
* its ALS scope, `getCurrentTenant()` here would return `undefined` and
|
|
116
|
+
* the reconciliation would silently no-op. We deliberately do NOT fall
|
|
117
|
+
* back to `event.locals.tenantId` here — that would re-introduce the
|
|
118
|
+
* stale-tenantContext bug from the subdomain handler. If the upstream
|
|
119
|
+
* scoping ever changes, this handle should be updated to read from a
|
|
120
|
+
* dedicated cross-handler carrier (e.g. `event.locals._tenantSource`)
|
|
121
|
+
* rather than guessing.
|
|
122
|
+
*/
|
|
123
|
+
const reconcileTenantLocals: Handle = async ({ event, resolve }) => {
|
|
124
|
+
const activeContext = getCurrentTenant();
|
|
125
|
+
if (activeContext) {
|
|
126
|
+
event.locals.tenantId = activeContext.tenantId;
|
|
127
|
+
event.locals.tenantContext = activeContext;
|
|
128
|
+
}
|
|
129
|
+
return resolve(event);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export const handle: Handle = sequence(
|
|
133
|
+
tenancyHandle,
|
|
134
|
+
sessionHandle,
|
|
135
|
+
reconcileTenantLocals,
|
|
136
|
+
);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Example SMRT Object
|
|
3
|
+
*
|
|
4
|
+
* This is a sample SMRT object to demonstrate the pattern.
|
|
5
|
+
* Rename or replace this with your own objects.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { SmrtObject, smrt } from '@happyvertical/smrt-core';
|
|
9
|
+
|
|
10
|
+
@smrt({
|
|
11
|
+
api: {
|
|
12
|
+
include: ['list', 'get', 'create', 'update', 'delete', 'summarize'],
|
|
13
|
+
},
|
|
14
|
+
cli: { include: ['list', 'get', 'summarize'] },
|
|
15
|
+
})
|
|
16
|
+
export class Item extends SmrtObject {
|
|
17
|
+
/** Item title */
|
|
18
|
+
title: string = '';
|
|
19
|
+
|
|
20
|
+
/** Item description */
|
|
21
|
+
description: string = '';
|
|
22
|
+
|
|
23
|
+
/** Status of the item */
|
|
24
|
+
status: string = 'draft';
|
|
25
|
+
|
|
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
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized SMRT Configuration
|
|
3
|
+
*
|
|
4
|
+
* This file provides configuration for all SMRT objects in your project.
|
|
5
|
+
* Import this file in your routes to get properly configured collections.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ObjectRegistry, type SmrtClassOptions } from '@happyvertical/smrt-core';
|
|
9
|
+
|
|
10
|
+
// Import all SMRT objects to register them
|
|
11
|
+
import '../objects/index.js';
|
|
12
|
+
|
|
13
|
+
declare global {
|
|
14
|
+
// eslint-disable-next-line no-var
|
|
15
|
+
var __smrtGetRequestScopedDatabase:
|
|
16
|
+
| (() => SmrtClassOptions['db'] | undefined)
|
|
17
|
+
| undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Per-object configuration overrides
|
|
22
|
+
*
|
|
23
|
+
* Use this to configure specific objects differently from defaults.
|
|
24
|
+
* Example:
|
|
25
|
+
* AuditLog: { db: { url: process.env.AUDIT_DB_URL!, type: 'postgres' } }
|
|
26
|
+
*/
|
|
27
|
+
const objectOverrides: Record<string, Partial<SmrtClassOptions>> = {
|
|
28
|
+
// Add object-specific overrides here
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get default configuration for SMRT objects
|
|
33
|
+
*/
|
|
34
|
+
function getDefaultConfig(): SmrtClassOptions {
|
|
35
|
+
return {
|
|
36
|
+
db: {
|
|
37
|
+
url: process.env.DATABASE_URL || './data/app.db',
|
|
38
|
+
type: (process.env.DATABASE_TYPE as 'sqlite' | 'postgres') || 'sqlite',
|
|
39
|
+
},
|
|
40
|
+
ai: process.env.OPENAI_API_KEY
|
|
41
|
+
? {
|
|
42
|
+
type: 'openai',
|
|
43
|
+
apiKey: process.env.OPENAI_API_KEY,
|
|
44
|
+
}
|
|
45
|
+
: undefined,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getRequestScopedDatabase(): SmrtClassOptions['db'] | undefined {
|
|
50
|
+
const getter = globalThis.__smrtGetRequestScopedDatabase;
|
|
51
|
+
return typeof getter === 'function' ? getter() : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get configuration for a specific SMRT class
|
|
56
|
+
*/
|
|
57
|
+
export function getSmrtConfig(className: string): SmrtClassOptions {
|
|
58
|
+
const defaults = getDefaultConfig();
|
|
59
|
+
const override = objectOverrides[className];
|
|
60
|
+
return override ? { ...defaults, ...override } : defaults;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get a collection instance for a SMRT class
|
|
65
|
+
*
|
|
66
|
+
* Usage in routes:
|
|
67
|
+
* const products = await getCollection<Product>('Product');
|
|
68
|
+
* const items = await products.list();
|
|
69
|
+
*/
|
|
70
|
+
export async function getCollection<T>(className: string) {
|
|
71
|
+
const config = getSmrtConfig(className);
|
|
72
|
+
const objectOverride = objectOverrides[className];
|
|
73
|
+
const requestScopedDb = objectOverride?.db
|
|
74
|
+
? undefined
|
|
75
|
+
: getRequestScopedDatabase();
|
|
76
|
+
|
|
77
|
+
return await ObjectRegistry.getCollection<T>(className, {
|
|
78
|
+
...config,
|
|
79
|
+
db: requestScopedDb ?? config.db,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant resolution for SvelteKit requests.
|
|
3
|
+
*
|
|
4
|
+
* This file is intentionally pluggable — consumers can swap the strategy
|
|
5
|
+
* function without forking. The shipped default is subdomain-based:
|
|
6
|
+
*
|
|
7
|
+
* acme.demo.local → tenantId='acme'
|
|
8
|
+
* www.demo.local → tenantId=null
|
|
9
|
+
* demo.local → tenantId=null
|
|
10
|
+
* localhost / 127.0.0.1 → tenantId=null
|
|
11
|
+
*
|
|
12
|
+
* The resolver's signature is intentionally minimal — it accepts a
|
|
13
|
+
* SvelteKit-like `RequestEvent` (typed structurally so this module has zero
|
|
14
|
+
* `@sveltejs/kit` runtime dependency) and returns a synchronous result.
|
|
15
|
+
*
|
|
16
|
+
* @example Swap to path-prefix resolution
|
|
17
|
+
* ```ts
|
|
18
|
+
* // src/lib/server/tenancy.ts (your project)
|
|
19
|
+
* import { createTenantResolver, pathPrefixStrategy } from './tenancy';
|
|
20
|
+
*
|
|
21
|
+
* export const resolveTenant = createTenantResolver(pathPrefixStrategy);
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* @example Swap to header-based resolution
|
|
25
|
+
* ```ts
|
|
26
|
+
* import { createTenantResolver, headerStrategy } from './tenancy';
|
|
27
|
+
*
|
|
28
|
+
* export const resolveTenant = createTenantResolver(
|
|
29
|
+
* headerStrategy({ headerName: 'x-tenant-id' }),
|
|
30
|
+
* );
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* @example Compose your own
|
|
34
|
+
* ```ts
|
|
35
|
+
* import { createTenantResolver, subdomainStrategy } from './tenancy';
|
|
36
|
+
*
|
|
37
|
+
* export const resolveTenant = createTenantResolver((event) => {
|
|
38
|
+
* // Try subdomain first, then fall back to a header
|
|
39
|
+
* const fromSubdomain = subdomainStrategy(event);
|
|
40
|
+
* if (fromSubdomain.tenantId) return fromSubdomain;
|
|
41
|
+
* return { tenantId: event.request.headers.get('x-tenant-id') };
|
|
42
|
+
* });
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Structural SvelteKit RequestEvent — only the bits we need for tenant
|
|
48
|
+
* resolution. Keeping this minimal means the module has no @sveltejs/kit
|
|
49
|
+
* runtime import, which keeps tests + non-SvelteKit consumers happy.
|
|
50
|
+
*/
|
|
51
|
+
export interface TenantResolverEvent {
|
|
52
|
+
url: URL;
|
|
53
|
+
request: { headers: Headers };
|
|
54
|
+
params?: Record<string, string | undefined>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolution result. `null` means "no tenant for this request".
|
|
59
|
+
*/
|
|
60
|
+
export interface TenantResolution {
|
|
61
|
+
tenantId: string | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A resolver strategy is just a function from event → resolution. Sync or
|
|
66
|
+
* async — the dispatcher awaits the return value.
|
|
67
|
+
*/
|
|
68
|
+
export type TenantResolverStrategy = (
|
|
69
|
+
event: TenantResolverEvent,
|
|
70
|
+
) => TenantResolution | Promise<TenantResolution>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Hostnames that should never produce a tenant id, regardless of how many
|
|
74
|
+
* dots they contain. Treats `localhost`, `127.0.0.1`, and `::1` as
|
|
75
|
+
* tenant-less so local dev "just works" until you set up a wildcard DNS
|
|
76
|
+
* entry like `*.demo.local → 127.0.0.1`.
|
|
77
|
+
*/
|
|
78
|
+
const ROOT_LIKE_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Subdomain labels that are *never* tenant slugs even if they appear in the
|
|
82
|
+
* leading position. Consumers can pass their own list to
|
|
83
|
+
* {@link subdomainStrategyWith} if they need to extend this.
|
|
84
|
+
*/
|
|
85
|
+
const DEFAULT_RESERVED_SUBDOMAINS = new Set(['www', 'api', 'app']);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Strategy: extract the tenant id from the leading subdomain.
|
|
89
|
+
*
|
|
90
|
+
* Behavior:
|
|
91
|
+
* - `acme.demo.local` → `{ tenantId: 'acme' }`
|
|
92
|
+
* - `acme.beta.demo.local` → `{ tenantId: 'acme' }` (only leading label)
|
|
93
|
+
* - `www.demo.local` → `{ tenantId: null }` (reserved)
|
|
94
|
+
* - `demo.local` → `{ tenantId: null }` (no leading subdomain)
|
|
95
|
+
* - `localhost` → `{ tenantId: null }`
|
|
96
|
+
* - `127.0.0.1` or any IPv4/IPv6 → `{ tenantId: null }`
|
|
97
|
+
*/
|
|
98
|
+
export const subdomainStrategy: TenantResolverStrategy = (event) => {
|
|
99
|
+
return subdomainStrategyWith()(event);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Customizable subdomain strategy. Use this if you need to extend the
|
|
104
|
+
* reserved-subdomain list (e.g. to also reject `admin.demo.local`) or
|
|
105
|
+
* to deploy on a multi-label public suffix like `example.co.uk` where
|
|
106
|
+
* a naive label count would treat the apex itself as a tenant.
|
|
107
|
+
*
|
|
108
|
+
* The supplied `reservedSubdomains` are **merged with** the built-in
|
|
109
|
+
* defaults (`www`, `api`, `app`) — `www`, `api`, and `app` always stay
|
|
110
|
+
* reserved regardless of what you pass. Pass `[]` or omit the option
|
|
111
|
+
* if you only need the defaults; the merge is additive-only. Replacing
|
|
112
|
+
* the default list outright is not currently supported via this
|
|
113
|
+
* strategy — fork the function or wire your own `TenantResolverStrategy`
|
|
114
|
+
* if you need that.
|
|
115
|
+
*
|
|
116
|
+
* `baseDomain` (strongly recommended for production) anchors the apex.
|
|
117
|
+
* When set, the strategy strips the matching suffix and treats whatever
|
|
118
|
+
* leading labels remain as the tenant. `example.co.uk` with
|
|
119
|
+
* `baseDomain: 'example.co.uk'` → no tenant; `acme.example.co.uk` →
|
|
120
|
+
* `'acme'`. Without `baseDomain` the strategy falls back to a label-
|
|
121
|
+
* count heuristic that works for `.com`/`.net`/`.dev`-style single-
|
|
122
|
+
* label TLDs but mis-identifies multi-label public suffixes (`.co.uk`,
|
|
123
|
+
* `.com.au`, `.gov.uk`, …) as tenant subdomains.
|
|
124
|
+
*/
|
|
125
|
+
export function subdomainStrategyWith(opts?: {
|
|
126
|
+
reservedSubdomains?: Iterable<string>;
|
|
127
|
+
/**
|
|
128
|
+
* Apex domain to anchor tenant detection against. Highly recommended
|
|
129
|
+
* for production deployments on multi-label public suffixes
|
|
130
|
+
* (`example.co.uk`, `example.com.au`, …). When set, a request for
|
|
131
|
+
* exactly that host returns `{ tenantId: null }`, and a request for
|
|
132
|
+
* `<tenant>.<baseDomain>` returns `{ tenantId: '<tenant>' }`. Subdomain
|
|
133
|
+
* resolution stops being label-count-dependent.
|
|
134
|
+
*/
|
|
135
|
+
baseDomain?: string;
|
|
136
|
+
}): TenantResolverStrategy {
|
|
137
|
+
const reserved = new Set<string>(DEFAULT_RESERVED_SUBDOMAINS);
|
|
138
|
+
if (opts?.reservedSubdomains) {
|
|
139
|
+
for (const s of opts.reservedSubdomains) {
|
|
140
|
+
reserved.add(s.toLowerCase());
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const baseDomain = opts?.baseDomain?.toLowerCase().replace(/^\.+|\.+$/g, '');
|
|
144
|
+
|
|
145
|
+
return (event) => {
|
|
146
|
+
const hostname = event.url.hostname.toLowerCase();
|
|
147
|
+
|
|
148
|
+
if (ROOT_LIKE_HOSTS.has(hostname)) {
|
|
149
|
+
return { tenantId: null };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Reject bare IPv4 (e.g. `192.168.1.10`) — never a tenant subdomain.
|
|
153
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) {
|
|
154
|
+
return { tenantId: null };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Preferred path: caller supplied a baseDomain. Strip it and read
|
|
158
|
+
// whatever's left as the tenant. This works correctly for both
|
|
159
|
+
// simple TLDs (`example.com`) and multi-label public suffixes
|
|
160
|
+
// (`example.co.uk`).
|
|
161
|
+
if (baseDomain) {
|
|
162
|
+
if (hostname === baseDomain) {
|
|
163
|
+
return { tenantId: null };
|
|
164
|
+
}
|
|
165
|
+
const suffix = `.${baseDomain}`;
|
|
166
|
+
if (hostname.endsWith(suffix)) {
|
|
167
|
+
const leading = hostname.slice(0, -suffix.length);
|
|
168
|
+
// Take only the first label of whatever's left so
|
|
169
|
+
// `acme.beta.example.co.uk` still resolves to `acme`.
|
|
170
|
+
const candidate = leading.split('.')[0];
|
|
171
|
+
if (!candidate || reserved.has(candidate)) {
|
|
172
|
+
return { tenantId: null };
|
|
173
|
+
}
|
|
174
|
+
return { tenantId: candidate };
|
|
175
|
+
}
|
|
176
|
+
// Host doesn't match the configured apex — out of scope for this
|
|
177
|
+
// strategy, treat as tenant-less rather than guess.
|
|
178
|
+
return { tenantId: null };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Fallback path: no baseDomain configured. Use a label-count
|
|
182
|
+
// heuristic that works for single-label TLDs. **This is incorrect
|
|
183
|
+
// for multi-label public suffixes** (`example.co.uk` has three
|
|
184
|
+
// labels and would be misread as `example` being a tenant on the
|
|
185
|
+
// `co.uk` root). Pass `baseDomain` to opt out of the heuristic on
|
|
186
|
+
// any production deployment that uses such a TLD.
|
|
187
|
+
const labels = hostname.split('.');
|
|
188
|
+
|
|
189
|
+
// Need at least 3 labels for a tenant subdomain: <tenant>.<root>.<tld>.
|
|
190
|
+
// `demo.local` (2 labels) has no subdomain.
|
|
191
|
+
if (labels.length < 3) {
|
|
192
|
+
return { tenantId: null };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const candidate = labels[0];
|
|
196
|
+
if (!candidate || reserved.has(candidate)) {
|
|
197
|
+
return { tenantId: null };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { tenantId: candidate };
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Strategy: extract the tenant id from a path prefix like `/t/<slug>/...`.
|
|
206
|
+
* The prefix is configurable; default is `/t/`.
|
|
207
|
+
*
|
|
208
|
+
* @example
|
|
209
|
+
* ```ts
|
|
210
|
+
* // matches /t/acme/dashboard → tenantId='acme'
|
|
211
|
+
* createTenantResolver(pathPrefixStrategy());
|
|
212
|
+
*
|
|
213
|
+
* // matches /tenant/acme/... → tenantId='acme'
|
|
214
|
+
* createTenantResolver(pathPrefixStrategy({ prefix: '/tenant/' }));
|
|
215
|
+
* ```
|
|
216
|
+
*/
|
|
217
|
+
export function pathPrefixStrategy(opts?: {
|
|
218
|
+
prefix?: string;
|
|
219
|
+
}): TenantResolverStrategy {
|
|
220
|
+
const prefix = opts?.prefix ?? '/t/';
|
|
221
|
+
return (event) => {
|
|
222
|
+
const path = event.url.pathname;
|
|
223
|
+
if (!path.startsWith(prefix)) {
|
|
224
|
+
return { tenantId: null };
|
|
225
|
+
}
|
|
226
|
+
const rest = path.slice(prefix.length);
|
|
227
|
+
const slug = rest.split('/')[0];
|
|
228
|
+
return { tenantId: slug && slug.length > 0 ? slug : null };
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Strategy: read the tenant id from a request header.
|
|
234
|
+
*
|
|
235
|
+
* @example
|
|
236
|
+
* ```ts
|
|
237
|
+
* createTenantResolver(headerStrategy({ headerName: 'x-tenant-id' }));
|
|
238
|
+
* ```
|
|
239
|
+
*/
|
|
240
|
+
export function headerStrategy(opts?: {
|
|
241
|
+
headerName?: string;
|
|
242
|
+
}): TenantResolverStrategy {
|
|
243
|
+
const headerName = opts?.headerName ?? 'x-tenant-id';
|
|
244
|
+
return (event) => {
|
|
245
|
+
const value = event.request.headers.get(headerName);
|
|
246
|
+
return { tenantId: value && value.length > 0 ? value : null };
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Wrap any strategy into a resolver. This is a thin factory — its purpose
|
|
252
|
+
* is to give consumers a single, stable entry point (`resolveTenant`) they
|
|
253
|
+
* can swap by changing only the argument.
|
|
254
|
+
*/
|
|
255
|
+
export function createTenantResolver(
|
|
256
|
+
strategy: TenantResolverStrategy,
|
|
257
|
+
): (event: TenantResolverEvent) => Promise<TenantResolution> {
|
|
258
|
+
return async (event) => {
|
|
259
|
+
return await strategy(event);
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Default resolver used by `hooks.server.ts`. Swap the argument to
|
|
265
|
+
* `createTenantResolver()` (or replace this export) to change strategies.
|
|
266
|
+
*/
|
|
267
|
+
export const resolveTenant = createTenantResolver(subdomainStrategy);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
let items: any[] = $state([]);
|
|
3
|
+
let loading = $state(true);
|
|
4
|
+
let error: string | null = $state(null);
|
|
5
|
+
|
|
6
|
+
$effect(() => {
|
|
7
|
+
fetchItems();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
async function fetchItems() {
|
|
11
|
+
try {
|
|
12
|
+
const response = await fetch('/api/items');
|
|
13
|
+
const data = await response.json();
|
|
14
|
+
items = data.items || [];
|
|
15
|
+
} catch (e) {
|
|
16
|
+
error = e instanceof Error ? e.message : 'Failed to fetch items';
|
|
17
|
+
} finally {
|
|
18
|
+
loading = false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<svelte:head>
|
|
24
|
+
<title>SMRT SvelteKit App</title>
|
|
25
|
+
</svelte:head>
|
|
26
|
+
|
|
27
|
+
<main>
|
|
28
|
+
<h1>Welcome to SMRT + SvelteKit</h1>
|
|
29
|
+
|
|
30
|
+
<section>
|
|
31
|
+
<h2>Items</h2>
|
|
32
|
+
|
|
33
|
+
{#if loading}
|
|
34
|
+
<p>Loading...</p>
|
|
35
|
+
{:else if error}
|
|
36
|
+
<p class="error">{error}</p>
|
|
37
|
+
{:else if items.length === 0}
|
|
38
|
+
<p>No items yet. Create your first item!</p>
|
|
39
|
+
{:else}
|
|
40
|
+
<ul>
|
|
41
|
+
{#each items as item}
|
|
42
|
+
<li>
|
|
43
|
+
<strong>{item.title}</strong>
|
|
44
|
+
<span class="status">{item.status}</span>
|
|
45
|
+
</li>
|
|
46
|
+
{/each}
|
|
47
|
+
</ul>
|
|
48
|
+
{/if}
|
|
49
|
+
</section>
|
|
50
|
+
|
|
51
|
+
<section>
|
|
52
|
+
<h2>Getting Started</h2>
|
|
53
|
+
<ul>
|
|
54
|
+
<li>Edit <code>src/lib/objects/Item.ts</code> to customize your SMRT object</li>
|
|
55
|
+
<li>Run <code>smrt objects</code> to see registered objects</li>
|
|
56
|
+
<li>Run <code>smrt generate-routes</code> to regenerate API routes</li>
|
|
57
|
+
<li>API routes are auto-generated in <code>src/routes/api/</code></li>
|
|
58
|
+
</ul>
|
|
59
|
+
</section>
|
|
60
|
+
</main>
|
|
61
|
+
|
|
62
|
+
<style>
|
|
63
|
+
main {
|
|
64
|
+
max-width: 800px;
|
|
65
|
+
margin: 0 auto;
|
|
66
|
+
padding: 2rem;
|
|
67
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
h1 {
|
|
71
|
+
color: #667eea;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
section {
|
|
75
|
+
margin: 2rem 0;
|
|
76
|
+
padding: 1rem;
|
|
77
|
+
background: #f5f5f5;
|
|
78
|
+
border-radius: var(--smrt-radius-md, 8px);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.error {
|
|
82
|
+
color: #c33;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.status {
|
|
86
|
+
font-size: 0.8em;
|
|
87
|
+
background: #e0e0e0;
|
|
88
|
+
padding: 2px 8px;
|
|
89
|
+
border-radius: var(--smrt-radius-sm, 4px);
|
|
90
|
+
margin-left: 8px;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
code {
|
|
94
|
+
background: #e0e0e0;
|
|
95
|
+
padding: 2px 6px;
|
|
96
|
+
border-radius: var(--smrt-radius-sm, 4px);
|
|
97
|
+
font-size: 0.9em;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
ul {
|
|
101
|
+
list-style: disc;
|
|
102
|
+
padding-left: 1.5rem;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
li {
|
|
106
|
+
margin: 0.5rem 0;
|
|
107
|
+
}
|
|
108
|
+
</style>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import adapter from '@sveltejs/adapter-auto';
|
|
2
|
+
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
|
3
|
+
|
|
4
|
+
/** @type {import('@sveltejs/kit').Config} */
|
|
5
|
+
const config = {
|
|
6
|
+
preprocess: vitePreprocess(),
|
|
7
|
+
|
|
8
|
+
kit: {
|
|
9
|
+
adapter: adapter(),
|
|
10
|
+
alias: {
|
|
11
|
+
$lib: 'src/lib',
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default config;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./.svelte-kit/tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"allowJs": true,
|
|
5
|
+
"checkJs": true,
|
|
6
|
+
"esModuleInterop": true,
|
|
7
|
+
"forceConsistentCasingInFileNames": true,
|
|
8
|
+
"resolveJsonModule": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"sourceMap": true,
|
|
11
|
+
"strict": true,
|
|
12
|
+
"moduleResolution": "bundler",
|
|
13
|
+
"experimentalDecorators": true,
|
|
14
|
+
"emitDecoratorMetadata": true
|
|
15
|
+
}
|
|
16
|
+
}
|