@saena-io/create 0.1.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 (37) hide show
  1. package/dist/index.js +313 -0
  2. package/package.json +24 -0
  3. package/template/addons/content/src/content/Hero.tsx +13 -0
  4. package/template/addons/content/src/content/admin.ts +5 -0
  5. package/template/addons/content/src/content/schema.ts +13 -0
  6. package/template/addons/content/src/content/sections.ts +6 -0
  7. package/template/addons/content/src/routes/index.tsx +21 -0
  8. package/template/addons/content/src/server/content.ts +25 -0
  9. package/template/base/.env.example +15 -0
  10. package/template/base/README.md +58 -0
  11. package/template/base/gitignore +14 -0
  12. package/template/base/package.json +40 -0
  13. package/template/base/scripts/migrate.ts +43 -0
  14. package/template/base/scripts/seed-staff.ts +68 -0
  15. package/template/base/src/admin/data.ts +261 -0
  16. package/template/base/src/admin/registry.ts +6 -0
  17. package/template/base/src/router.tsx +19 -0
  18. package/template/base/src/routes/__root.tsx +34 -0
  19. package/template/base/src/routes/admin/$.tsx +17 -0
  20. package/template/base/src/routes/admin/index.tsx +8 -0
  21. package/template/base/src/routes/admin/route.tsx +29 -0
  22. package/template/base/src/routes/api/assets/$.ts +125 -0
  23. package/template/base/src/routes/api/assets/upload.ts +99 -0
  24. package/template/base/src/routes/api/auth/staff/$.ts +12 -0
  25. package/template/base/src/routes/api/plugin/$.ts +51 -0
  26. package/template/base/src/routes/index.tsx +19 -0
  27. package/template/base/src/server/admin-context.ts +39 -0
  28. package/template/base/src/server/auth.ts +32 -0
  29. package/template/base/src/server/branding.ts +19 -0
  30. package/template/base/src/server/business-profile.ts +19 -0
  31. package/template/base/src/server/core.ts +41 -0
  32. package/template/base/src/server/plugin-host.ts +21 -0
  33. package/template/base/src/server/require-ctx.ts +25 -0
  34. package/template/base/src/server/team.ts +58 -0
  35. package/template/base/src/server/translations.ts +74 -0
  36. package/template/base/tsconfig.json +22 -0
  37. package/template/base/vite.config.ts +25 -0
@@ -0,0 +1,261 @@
1
+ import { getAdminContextFn } from '@/server/admin-context';
2
+ import { getBrandingFn, saveBrandingFn } from '@/server/branding';
3
+ import { getBusinessProfileFn, saveBusinessProfileFn } from '@/server/business-profile';
4
+ import {
5
+ createStaffFn,
6
+ deleteStaffFn,
7
+ listRolesFn,
8
+ listStaffFn,
9
+ updateStaffRoleFn,
10
+ } from '@/server/team';
11
+ import {
12
+ addLocaleFn,
13
+ listLocalesFn,
14
+ listTranslationsFn,
15
+ removeLocaleFn,
16
+ setMainLocaleFn,
17
+ setReviewedFn,
18
+ setTranslationFn,
19
+ } from '@/server/translations';
20
+ import {
21
+ ADMIN_CONTEXT_KEY,
22
+ type AdminContextRecord,
23
+ type AdminData,
24
+ BRANDING_KEY,
25
+ BUSINESS_PROFILE_KEY,
26
+ type BrandingRecord,
27
+ type BusinessProfileRecord,
28
+ type LocaleRecord,
29
+ // SAENA:translation:line
30
+ MACHINE_TRANSLATE_SERVICE,
31
+ type RoleRecord,
32
+ type StaffRecord,
33
+ type TranslationCell,
34
+ type TranslationEdit,
35
+ } from '@saena-io/admin';
36
+ import type { StaffCreateInput } from '@saena-io/plugin-sdk';
37
+ // SAENA:translation:start
38
+ import { translationFns } from '@saena-io/translation/contract';
39
+ // SAENA:translation:end
40
+ import { createCollection } from '@tanstack/db';
41
+ import { QueryClient } from '@tanstack/query-core';
42
+ import { queryCollectionOptions } from '@tanstack/query-db-collection';
43
+
44
+ // The host-built admin data layer: TanStack DB collections that call the server functions (which reach
45
+ // core) and are injected into the shell via <AdminDataProvider>, so @saena-io/admin never imports the host
46
+ // or core (the boundary, ARCHITECTURE.md §13). Collections start syncing as soon as they're created, so
47
+ // they are built LAZILY — once, on first use by the authed shell (getAdminData below) — to avoid a
48
+ // wasted, unauthorised fetch on the public login page.
49
+
50
+ let cached: AdminData | undefined;
51
+
52
+ /** Build the admin collections once, on first call. Called from inside the authenticated shell only. */
53
+ export function getAdminData(): AdminData {
54
+ if (cached) return cached;
55
+
56
+ const queryClient = new QueryClient();
57
+
58
+ // The business profile is a single project-wide record (§5); model it as a one-row collection keyed by
59
+ // a constant so admin pages read it with useLiveQuery and write optimistically through the server
60
+ // functions.
61
+ const businessProfile = createCollection(
62
+ queryCollectionOptions<BusinessProfileRecord>({
63
+ id: 'business-profile',
64
+ queryKey: ['business-profile'],
65
+ queryClient,
66
+ getKey: (item) => item.id,
67
+ queryFn: async () => {
68
+ const profile = await getBusinessProfileFn();
69
+ return profile ? [{ id: BUSINESS_PROFILE_KEY, ...profile }] : [];
70
+ },
71
+ // Insert (first-time) and update both map to the single upsert server fn. The synthetic `id` is
72
+ // dropped by the schema validator (zod strips unknown keys) before it reaches core.
73
+ onInsert: async ({ transaction }) => {
74
+ for (const m of transaction.mutations) await saveBusinessProfileFn({ data: m.modified });
75
+ },
76
+ onUpdate: async ({ transaction }) => {
77
+ for (const m of transaction.mutations) await saveBusinessProfileFn({ data: m.modified });
78
+ },
79
+ }),
80
+ );
81
+
82
+ // Branding is another single project-wide record (§5) — the same one-row collection shape as the
83
+ // business profile, keyed by a constant and upserted through one server function.
84
+ const branding = createCollection(
85
+ queryCollectionOptions<BrandingRecord>({
86
+ id: 'branding',
87
+ queryKey: ['branding'],
88
+ queryClient,
89
+ getKey: (item) => item.id,
90
+ queryFn: async () => {
91
+ const record = await getBrandingFn();
92
+ return record ? [{ id: BRANDING_KEY, ...record }] : [];
93
+ },
94
+ onInsert: async ({ transaction }) => {
95
+ for (const m of transaction.mutations) await saveBrandingFn({ data: m.modified });
96
+ },
97
+ onUpdate: async ({ transaction }) => {
98
+ for (const m of transaction.mutations) await saveBrandingFn({ data: m.modified });
99
+ },
100
+ }),
101
+ );
102
+
103
+ // Team / RBAC (§10). Staff is read + role-reassign + remove via the collection; CREATE is a one-shot
104
+ // action (createStaff) because better-auth owns the id and the password must not enter the collection.
105
+ const staff = createCollection(
106
+ queryCollectionOptions<StaffRecord>({
107
+ id: 'staff',
108
+ queryKey: ['staff'],
109
+ queryClient,
110
+ getKey: (item) => item.id,
111
+ queryFn: () => listStaffFn(),
112
+ onUpdate: async ({ transaction }) => {
113
+ for (const m of transaction.mutations) await updateStaffRoleFn({ data: m.modified });
114
+ },
115
+ onDelete: async ({ transaction }) => {
116
+ for (const m of transaction.mutations) await deleteStaffFn({ data: { id: String(m.key) } });
117
+ },
118
+ }),
119
+ );
120
+
121
+ // Roles are read-only (ADR-0012 phase 3): the predefined Admin/Owner/Editor, seeded at deploy by
122
+ // `ensureRoles`. The admin lists them to assign to members + shows a read-only reference — no create/edit/
123
+ // delete, so the collection has no mutation handlers.
124
+ const roles = createCollection(
125
+ queryCollectionOptions<RoleRecord>({
126
+ id: 'roles',
127
+ queryKey: ['roles'],
128
+ queryClient,
129
+ getKey: (item) => item.id,
130
+ queryFn: () => listRolesFn(),
131
+ }),
132
+ );
133
+
134
+ // The admin context (ADR-0012 phase 3): the signed-in actor's permission ids (for advisory nav/section
135
+ // gating) + the read-only predefined-role reference. A one-row collection keyed by a constant, like the
136
+ // single-record settings above; read-only (no mutations).
137
+ const adminContext = createCollection(
138
+ queryCollectionOptions<AdminContextRecord>({
139
+ id: 'admin-context',
140
+ queryKey: ['admin-context'],
141
+ queryClient,
142
+ getKey: (item) => item.id,
143
+ queryFn: async () => [{ id: ADMIN_CONTEXT_KEY, ...(await getAdminContextFn()) }],
144
+ }),
145
+ );
146
+
147
+ const createStaff = async (input: StaffCreateInput): Promise<void> => {
148
+ await createStaffFn({ data: input });
149
+ await queryClient.invalidateQueries({ queryKey: ['staff'] });
150
+ };
151
+
152
+ // Translations (§9, ADR-0005) — every (key, locale) value as a grid cell, loaded whole and
153
+ // searched/filtered client-side. The grid READS this; writes go through the actions below (which
154
+ // refetch), because editing the source re-stales sibling cells — a ripple a single optimistic row
155
+ // update can't express.
156
+ const translations = createCollection(
157
+ queryCollectionOptions<TranslationCell>({
158
+ id: 'translations',
159
+ queryKey: ['translations'],
160
+ queryClient,
161
+ getKey: (item) => item.id,
162
+ queryFn: async () => {
163
+ const rows = await listTranslationsFn();
164
+ return rows.map((r) => ({ id: `${r.key}::${r.locale}`, ...r }));
165
+ },
166
+ }),
167
+ );
168
+
169
+ // Project locales (read-only here; the source-locale swap is the setMainLocale action below, and
170
+ // add/remove-locale lands with the Languages settings UI).
171
+ const locales = createCollection(
172
+ queryCollectionOptions<LocaleRecord>({
173
+ id: 'locales',
174
+ queryKey: ['locales'],
175
+ queryClient,
176
+ getKey: (item) => item.code,
177
+ queryFn: () => listLocalesFn(),
178
+ }),
179
+ );
180
+
181
+ // Persist edits in the given order (caller passes the source edit first, so the translations it
182
+ // re-stales then re-anchor correctly), then refetch so the grid reflects the new values + staleness.
183
+ const saveTranslations = async (edits: TranslationEdit[]): Promise<void> => {
184
+ for (const e of edits) {
185
+ await setTranslationFn({ data: { key: e.key, locale: e.locale, text: e.text } });
186
+ }
187
+ await queryClient.invalidateQueries({ queryKey: ['translations'] });
188
+ };
189
+
190
+ const setTranslationReviewed = async (
191
+ key: string,
192
+ locale: string,
193
+ reviewed: boolean,
194
+ ): Promise<void> => {
195
+ await setReviewedFn({ data: { key, locale, reviewed } });
196
+ await queryClient.invalidateQueries({ queryKey: ['translations'] });
197
+ };
198
+
199
+ const addLocale = async (code: string, label: string): Promise<void> => {
200
+ await addLocaleFn({ data: { code, label } });
201
+ await queryClient.invalidateQueries({ queryKey: ['locales'] });
202
+ };
203
+
204
+ // Removing a locale deletes its translation values too, so refetch the grid as well.
205
+ const removeLocale = async (code: string): Promise<void> => {
206
+ await removeLocaleFn({ data: { code } });
207
+ await queryClient.invalidateQueries({ queryKey: ['locales'] });
208
+ await queryClient.invalidateQueries({ queryKey: ['translations'] });
209
+ };
210
+
211
+ // Swapping the main locale re-anchors staleness across every key server-side, so refetch both.
212
+ const setMainLocale = async (code: string): Promise<void> => {
213
+ await setMainLocaleFn({ data: { code } });
214
+ await queryClient.invalidateQueries({ queryKey: ['locales'] });
215
+ await queryClient.invalidateQueries({ queryKey: ['translations'] });
216
+ };
217
+
218
+ // SAENA:translation:start
219
+ // Machine/AI translation (ADR-0010): call the @saena-io/translation plugin fn via the generic /api/plugin
220
+ // dispatch. It writes an origin:'machine' value server-side, so refetch the grid to surface the chip.
221
+ const machineTranslate = async (input: {
222
+ key: string;
223
+ locale: string;
224
+ sourceText: string;
225
+ format: string;
226
+ }): Promise<{ text: string }> => {
227
+ const res = await fetch(`/api/plugin/${translationFns.translate}`, {
228
+ method: 'POST',
229
+ headers: { 'content-type': 'application/json' },
230
+ body: JSON.stringify(input),
231
+ });
232
+ if (!res.ok) {
233
+ const body = (await res.json().catch(() => null)) as { error?: string } | null;
234
+ throw new Error(body?.error ?? `translate failed (${res.status})`);
235
+ }
236
+ const result = (await res.json()) as { text: string };
237
+ await queryClient.invalidateQueries({ queryKey: ['translations'] });
238
+ return result;
239
+ };
240
+ // SAENA:translation:end
241
+
242
+ cached = {
243
+ businessProfile,
244
+ branding,
245
+ staff,
246
+ roles,
247
+ adminContext,
248
+ createStaff,
249
+ translations,
250
+ locales,
251
+ addLocale,
252
+ removeLocale,
253
+ saveTranslations,
254
+ setTranslationReviewed,
255
+ setMainLocale,
256
+ // SAENA:translation:start
257
+ services: { [MACHINE_TRANSLATE_SERVICE]: machineTranslate },
258
+ // SAENA:translation:end
259
+ };
260
+ return cached;
261
+ }
@@ -0,0 +1,6 @@
1
+ import { adminExtensions } from '@/plugins-admin';
2
+ import { buildAdminRegistry } from '@saena-io/admin';
3
+
4
+ // Plugin admin extensions, composed from the project manifest (ADR-0006). The core's fixed nav lives in
5
+ // @saena-io/admin's coreRegistry; this is the plugin layer. The shell reads the result and never names a plugin.
6
+ export const pluginRegistry = buildAdminRegistry(adminExtensions);
@@ -0,0 +1,19 @@
1
+ import { createRouter as createTanStackRouter } from '@tanstack/react-router';
2
+ import { routeTree } from './routeTree.gen';
3
+
4
+ export function getRouter() {
5
+ const router = createTanStackRouter({
6
+ routeTree,
7
+ scrollRestoration: true,
8
+ defaultPreload: 'intent',
9
+ defaultPreloadStaleTime: 0,
10
+ });
11
+
12
+ return router;
13
+ }
14
+
15
+ declare module '@tanstack/react-router' {
16
+ interface Register {
17
+ router: ReturnType<typeof getRouter>;
18
+ }
19
+ }
@@ -0,0 +1,34 @@
1
+ import appCss from '@saena-io/ui/globals.css?url';
2
+ import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router';
3
+
4
+ export const Route = createRootRoute({
5
+ head: () => ({
6
+ meta: [
7
+ { charSet: 'utf-8' },
8
+ { name: 'viewport', content: 'width=device-width, initial-scale=1' },
9
+ { title: 'SAENA' },
10
+ ],
11
+ links: [{ rel: 'stylesheet', href: appCss }],
12
+ }),
13
+ notFoundComponent: () => (
14
+ <main className="container mx-auto p-4 pt-16">
15
+ <h1>404</h1>
16
+ <p>The requested page could not be found.</p>
17
+ </main>
18
+ ),
19
+ shellComponent: RootDocument,
20
+ });
21
+
22
+ function RootDocument({ children }: { children: React.ReactNode }) {
23
+ return (
24
+ <html lang="en">
25
+ <head>
26
+ <HeadContent />
27
+ </head>
28
+ <body>
29
+ {children}
30
+ <Scripts />
31
+ </body>
32
+ </html>
33
+ );
34
+ }
@@ -0,0 +1,17 @@
1
+ import { pluginRegistry } from '@/admin/registry';
2
+ import { coreRegistry } from '@saena-io/admin';
3
+ import { createFileRoute } from '@tanstack/react-router';
4
+
5
+ // /admin/<path> → the core or plugin screen mapped to that path.
6
+ export const Route = createFileRoute('/admin/$')({ component: AdminScreen });
7
+
8
+ function AdminScreen() {
9
+ const { _splat } = Route.useParams();
10
+ const path = _splat ?? '';
11
+ const match = coreRegistry.routes.get(path) ?? pluginRegistry.routes.get(path);
12
+ if (!match) {
13
+ return <div className="p-6 text-muted-foreground text-sm">Screen not found: {path}</div>;
14
+ }
15
+ const Screen = match.component;
16
+ return <Screen />;
17
+ }
@@ -0,0 +1,8 @@
1
+ import { pluginRegistry } from '@/admin/registry';
2
+ import { Dashboard, coreRegistry } from '@saena-io/admin';
3
+ import { createFileRoute } from '@tanstack/react-router';
4
+
5
+ // /admin → the dashboard (core + plugin widgets).
6
+ export const Route = createFileRoute('/admin/')({
7
+ component: () => <Dashboard widgets={[...coreRegistry.widgets, ...pluginRegistry.widgets]} />,
8
+ });
@@ -0,0 +1,29 @@
1
+ import { getAdminData } from '@/admin/data';
2
+ import { pluginRegistry } from '@/admin/registry';
3
+ import { AdminDataProvider, AppShell, AuthGate } from '@saena-io/admin';
4
+ import { Outlet, createFileRoute } from '@tanstack/react-router';
5
+
6
+ // The /admin layout: gate first, then the data provider (host-built collections) + the shell (core nav
7
+ // built in, plugin nav/settings from the registry).
8
+ export const Route = createFileRoute('/admin')({ component: AdminLayout });
9
+
10
+ function AdminLayout() {
11
+ return (
12
+ <AuthGate>
13
+ <AdminShell />
14
+ </AuthGate>
15
+ );
16
+ }
17
+
18
+ // Rendered only once authed (as AuthGate's child), so the host collections are built — and begin
19
+ // fetching — here, never on the public login page.
20
+ function AdminShell() {
21
+ const adminData = getAdminData();
22
+ return (
23
+ <AdminDataProvider value={adminData}>
24
+ <AppShell pluginNav={pluginRegistry.nav} pluginSettings={pluginRegistry.settings}>
25
+ <Outlet />
26
+ </AppShell>
27
+ </AdminDataProvider>
28
+ );
29
+ }
@@ -0,0 +1,125 @@
1
+ import { resolveRequestActor } from '@/server/auth';
2
+ import { getCoreContext, getCoreDb } from '@/server/core';
3
+ import { createRequestContext } from '@saena-io/core';
4
+ import { IMAGE_WIDTH_LADDER, type ImageTransform } from '@saena-io/plugin-sdk';
5
+ import { createFileRoute } from '@tanstack/react-router';
6
+
7
+ // Asset serving (ADR-0002 + ADR-0009): GET /api/assets/<id> streams the stored bytes. Resolves by registry id
8
+ // (never a raw path → no FS-layout leak / traversal). Public assets serve to anyone; private assets require a
9
+ // staff actor (404, not 403, so existence isn't confirmed). nosniff always; public assets are immutably
10
+ // cacheable (a replace mints a new id), private assets must never enter a shared cache.
11
+ //
12
+ // With image transform params (w/ar/fx/fy/fmt) the route serves a derived variant instead of the original —
13
+ // cropped to a ratio centred on the focal point, resized, and re-encoded. Derivatives are content-stable (the
14
+ // id + params fully determine the bytes), so public ones are immutable too. No params ⇒ the original, as before.
15
+
16
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
17
+
18
+ const clamp = (n: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, n));
19
+
20
+ /** Parse + clamp the derivative params from the query string. Returns null when none are present (serve the
21
+ * original). Oversized/garbage values clamp or drop rather than erroring. */
22
+ function parseTransform(params: URLSearchParams): ImageTransform | null {
23
+ // Snap the requested width to the nearest ladder entry: the public reader only ever emits ladder widths
24
+ // (identity-snap), while a direct/hostile caller can't balloon the derivative cache with arbitrary widths.
25
+ let width: number | undefined;
26
+ const w = Number(params.get('w'));
27
+ if (Number.isFinite(w) && w > 0) {
28
+ width = IMAGE_WIDTH_LADDER.reduce((best, cand) =>
29
+ Math.abs(cand - w) < Math.abs(best - w) ? cand : best,
30
+ );
31
+ }
32
+
33
+ let ar: number | undefined;
34
+ const arRaw = params.get('ar');
35
+ if (arRaw) {
36
+ let parsed: number | undefined;
37
+ if (arRaw.includes(':')) {
38
+ const [a, b] = arRaw.split(':').map(Number);
39
+ if (Number.isFinite(a) && Number.isFinite(b) && a > 0 && b > 0) parsed = a / b;
40
+ } else {
41
+ const n = Number(arRaw);
42
+ if (Number.isFinite(n) && n > 0) parsed = n;
43
+ }
44
+ if (parsed !== undefined) ar = clamp(parsed, 0.05, 20);
45
+ }
46
+
47
+ const unit = (key: string): number | undefined => {
48
+ const raw = params.get(key);
49
+ if (raw === null) return undefined;
50
+ const n = Number(raw);
51
+ return Number.isFinite(n) ? clamp(n, 0, 1) : undefined;
52
+ };
53
+ const fx = unit('fx');
54
+ const fy = unit('fy');
55
+
56
+ const fmtRaw = params.get('fmt');
57
+ const format = fmtRaw === 'webp' || fmtRaw === 'avif' || fmtRaw === 'orig' ? fmtRaw : undefined;
58
+
59
+ let q: number | undefined;
60
+ const qRaw = Number(params.get('q'));
61
+ if (Number.isFinite(qRaw) && qRaw > 0) q = Math.round(clamp(qRaw, 1, 100));
62
+
63
+ // `q` only matters alongside a real transform (it's an encoder lever) — don't trigger a derive on its own.
64
+ if (width === undefined && ar === undefined && format === undefined) return null;
65
+ return { width, ar, fx, fy, format, q };
66
+ }
67
+
68
+ /** Build the byte response with the right caching posture. Copy into a fresh ArrayBuffer — a guaranteed
69
+ * BodyInit regardless of the source Uint8Array's backing buffer. */
70
+ function bytesResponse(bytes: Uint8Array, mime: string, isPublic: boolean): Response {
71
+ const body = new ArrayBuffer(bytes.byteLength);
72
+ new Uint8Array(body).set(bytes);
73
+ const headers: Record<string, string> = {
74
+ 'content-type': mime,
75
+ 'content-length': String(bytes.byteLength),
76
+ 'cache-control': isPublic ? 'public, max-age=31536000, immutable' : 'private, no-store',
77
+ 'x-content-type-options': 'nosniff',
78
+ };
79
+ if (!isPublic) headers.vary = 'Cookie';
80
+ return new Response(body, { status: 200, headers });
81
+ }
82
+
83
+ async function serve(request: Request): Promise<Response> {
84
+ const notFound = () => new Response('Not found', { status: 404 });
85
+
86
+ const url = new URL(request.url);
87
+ let id: string;
88
+ try {
89
+ id = decodeURIComponent(url.pathname.replace(/^\/api\/assets\//, ''));
90
+ } catch {
91
+ return notFound(); // malformed percent-encoding
92
+ }
93
+ // Guard the shape so a non-uuid never reaches the uuid column (→ a clean 404, not a 500).
94
+ if (!UUID_RE.test(id)) return notFound();
95
+
96
+ const core = getCoreContext();
97
+ const stored = await core.storage.get(id);
98
+ if (!stored) return notFound();
99
+
100
+ if (!stored.ref.public) {
101
+ const ctx = await createRequestContext({
102
+ core,
103
+ db: getCoreDb(),
104
+ resolveActor: resolveRequestActor,
105
+ headers: request.headers,
106
+ });
107
+ if (ctx.actor.kind !== 'staff') return notFound();
108
+ }
109
+
110
+ // Image derivative (ADR-0009): generated + cached behind the storage seam. `derive` returns null for a
111
+ // non-image or an unreadable/untransformable source — fall through to the original in that case.
112
+ const spec = parseTransform(url.searchParams);
113
+ if (spec) {
114
+ const derived = await core.storage.derive(id, spec);
115
+ if (derived) return bytesResponse(derived.bytes, derived.mime, stored.ref.public);
116
+ }
117
+
118
+ const bytes = await core.storage.read(stored.storageKey);
119
+ if (!bytes) return notFound();
120
+ return bytesResponse(bytes, stored.ref.mime, stored.ref.public);
121
+ }
122
+
123
+ export const Route = createFileRoute('/api/assets/$')({
124
+ server: { handlers: { GET: ({ request }: { request: Request }) => serve(request) } },
125
+ });
@@ -0,0 +1,99 @@
1
+ import { resolveRequestActor } from '@/server/auth';
2
+ import { getCoreContext, getCoreDb } from '@/server/core';
3
+ import { PermissionDeniedError, createRequestContext } from '@saena-io/core';
4
+ import { createFileRoute } from '@tanstack/react-router';
5
+
6
+ // Asset upload (ADR-0002): POST /api/assets/upload with a multipart `file`. Gated by `content.manage` (the only
7
+ // uploader today is the content plugin). Uses a raw ServerRoute handler — `createServerFn` is JSON-RPC and can't
8
+ // carry a File. Returns the AssetRef the editor stores in content config.
9
+
10
+ const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; // 10 MB
11
+ // SVG is excluded deliberately — inline SVG is an XSS vector when served from our origin (ADR-0002 v1).
12
+ const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/avif']);
13
+
14
+ /**
15
+ * Detect an image's real type from its magic bytes — the client-declared `file.type` is attacker-controlled
16
+ * and trivially spoofed (M7 security review). We store the SNIFFED type, never the declared one, so a file
17
+ * mislabeled as an allowed image can't slip through.
18
+ */
19
+ function sniffImageMime(b: Uint8Array): string | null {
20
+ if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47)
21
+ return 'image/png';
22
+ if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg';
23
+ if (b.length >= 6 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38)
24
+ return 'image/gif';
25
+ if (
26
+ b.length >= 12 &&
27
+ b[0] === 0x52 &&
28
+ b[1] === 0x49 &&
29
+ b[2] === 0x46 &&
30
+ b[3] === 0x46 && // "RIFF"
31
+ b[8] === 0x57 &&
32
+ b[9] === 0x45 &&
33
+ b[10] === 0x42 &&
34
+ b[11] === 0x50 // "WEBP"
35
+ )
36
+ return 'image/webp';
37
+ if (
38
+ b.length >= 12 &&
39
+ b[4] === 0x66 &&
40
+ b[5] === 0x74 &&
41
+ b[6] === 0x79 &&
42
+ b[7] === 0x70 && // "ftyp"
43
+ b[8] === 0x61 &&
44
+ b[9] === 0x76 &&
45
+ b[10] === 0x69 &&
46
+ b[11] === 0x66 // "avif"
47
+ )
48
+ return 'image/avif';
49
+ return null;
50
+ }
51
+
52
+ function json(body: unknown, status = 200): Response {
53
+ return new Response(JSON.stringify(body), {
54
+ status,
55
+ headers: { 'content-type': 'application/json' },
56
+ });
57
+ }
58
+
59
+ async function upload(request: Request): Promise<Response> {
60
+ const ctx = await createRequestContext({
61
+ core: getCoreContext(),
62
+ db: getCoreDb(),
63
+ resolveActor: resolveRequestActor,
64
+ headers: request.headers,
65
+ });
66
+ try {
67
+ ctx.requirePermission('content.manage');
68
+ } catch (err) {
69
+ if (err instanceof PermissionDeniedError) return json({ error: err.message }, 403);
70
+ throw err;
71
+ }
72
+
73
+ const form = await request.formData().catch(() => null);
74
+ const file = form?.get('file');
75
+ if (!(file instanceof File)) return json({ error: 'no file provided' }, 400);
76
+ if (file.size > MAX_UPLOAD_BYTES) return json({ error: 'file too large (max 10 MB)' }, 413);
77
+
78
+ try {
79
+ const bytes = new Uint8Array(await file.arrayBuffer());
80
+ // Validate by content, not the spoofable declared type; store the sniffed mime.
81
+ const mime = sniffImageMime(bytes);
82
+ if (!mime || !ALLOWED_MIME.has(mime)) {
83
+ return json(
84
+ { error: 'unsupported or invalid image (allowed: png, jpeg, webp, gif, avif)' },
85
+ 415,
86
+ );
87
+ }
88
+ const createdBy = ctx.actor.kind === 'staff' ? ctx.actor.userId : undefined;
89
+ const ref = await ctx.storage.put({ bytes, mime, isPublic: true, createdBy });
90
+ return json(ref, 201);
91
+ } catch (err) {
92
+ ctx.logger.error('asset upload failed', { err: String(err) });
93
+ return json({ error: 'upload failed' }, 500);
94
+ }
95
+ }
96
+
97
+ export const Route = createFileRoute('/api/assets/upload')({
98
+ server: { handlers: { POST: ({ request }: { request: Request }) => upload(request) } },
99
+ });
@@ -0,0 +1,12 @@
1
+ import { getStaffAuth } from '@/server/auth';
2
+ import { createFileRoute } from '@tanstack/react-router';
3
+
4
+ // Mounts the staff better-auth handler at /api/auth/staff/* (sign-in, get-session, sign-out, …).
5
+ export const Route = createFileRoute('/api/auth/staff/$')({
6
+ server: {
7
+ handlers: {
8
+ GET: ({ request }: { request: Request }) => getStaffAuth().handler(request),
9
+ POST: ({ request }: { request: Request }) => getStaffAuth().handler(request),
10
+ },
11
+ },
12
+ });
@@ -0,0 +1,51 @@
1
+ import { resolveRequestActor } from '@/server/auth';
2
+ import { getCoreContext, getCoreDb } from '@/server/core';
3
+ import { getPluginHost } from '@/server/plugin-host';
4
+ import { PermissionDeniedError, createRequestContext } from '@saena-io/core';
5
+ import { createFileRoute } from '@tanstack/react-router';
6
+
7
+ // Generic plugin API dispatch (ADR-0006): one catch-all route exposes every enabled plugin's `Plugin.api`
8
+ // without the host app ever naming a plugin. POST /api/plugin/<fnId> → build a per-request RequestContext
9
+ // (resolved actor + locale + requirePermission), resolve the function by id across the host's plugins,
10
+ // enforce its declared permission (defense-in-depth on top of the handler's own checks), and run it.
11
+
12
+ function json(body: unknown, status = 200): Response {
13
+ return new Response(JSON.stringify(body), {
14
+ status,
15
+ headers: { 'content-type': 'application/json' },
16
+ });
17
+ }
18
+
19
+ async function dispatch(request: Request): Promise<Response> {
20
+ const fnId = decodeURIComponent(new URL(request.url).pathname.replace(/^\/api\/plugin\//, ''));
21
+ const fn = getPluginHost()
22
+ .plugins.flatMap((p) => p.api ?? [])
23
+ .find((f) => f.id === fnId);
24
+ if (!fn) return json({ error: `unknown plugin function: ${fnId}` }, 404);
25
+
26
+ const input = await request.json().catch(() => undefined);
27
+ const ctx = await createRequestContext({
28
+ core: getCoreContext(),
29
+ db: getCoreDb(),
30
+ resolveActor: resolveRequestActor,
31
+ headers: request.headers,
32
+ });
33
+
34
+ try {
35
+ // Default-deny (M7 security review): a function with a declared permission enforces it; one without is
36
+ // still gated to a signed-in actor unless it explicitly opts `public: true`. Closes anonymous access to
37
+ // permission-less plugin functions.
38
+ if (fn.permission) ctx.requirePermission(fn.permission);
39
+ else if (!fn.public && ctx.actor.kind === 'anonymous')
40
+ return json({ error: 'authentication required' }, 401);
41
+ return json(await fn.handler(input, ctx));
42
+ } catch (err) {
43
+ if (err instanceof PermissionDeniedError) return json({ error: err.message }, 403);
44
+ ctx.logger.error('plugin dispatch failed', { fnId, err: String(err) });
45
+ return json({ error: 'internal error' }, 500);
46
+ }
47
+ }
48
+
49
+ export const Route = createFileRoute('/api/plugin/$')({
50
+ server: { handlers: { POST: ({ request }: { request: Request }) => dispatch(request) } },
51
+ });