@storyshelf/app 0.6.1 → 0.6.2
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/dist/credentials.mjs +3 -2
- package/dist/credentials.mjs.map +1 -1
- package/dist/index.mjs +3 -3
- package/dist/index.mjs.map +1 -1
- package/dist/lifecycle.mjs +19 -1
- package/dist/lifecycle.mjs.map +1 -1
- package/dist/middleware/auth-gate.mjs.map +1 -1
- package/dist/routers/admin-pages.mjs.map +1 -1
- package/dist/routers/auth.mjs +1 -1
- package/dist/routers/auth.mjs.map +1 -1
- package/dist/routers/profile.mjs +1 -1
- package/dist/routers/profile.mjs.map +1 -1
- package/dist/routers/well-known.mjs.map +1 -1
- package/package.json +8 -5
package/dist/credentials.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import "node:path";
|
|
2
2
|
import "node:url";
|
|
3
3
|
import.meta.url;
|
|
4
|
+
import { trackBackground } from "./lifecycle.mjs";
|
|
4
5
|
import { probeCredentials, reencryptCredentials } from "@storyshelf/core/models";
|
|
5
6
|
//#region src/credentials.ts
|
|
6
7
|
/** Current/previous key pair for a probe, or null when no secret is configured. */
|
|
@@ -44,11 +45,11 @@ async function checkCredentialsAtBoot(db, config, logger) {
|
|
|
44
45
|
}
|
|
45
46
|
/** Run {@link checkCredentialsAtBoot} once adapters are ready; never blocks or throws. */
|
|
46
47
|
function checkCredentialsAfterSetup(cell, db, config, logger) {
|
|
47
|
-
cell.ready.then(async (setup) => {
|
|
48
|
+
trackBackground(cell, cell.ready.then(async (setup) => {
|
|
48
49
|
if (setup.ok) await checkCredentialsAtBoot(db, config, logger);
|
|
49
50
|
}).catch((error) => {
|
|
50
51
|
logger.error({ err: error }, "credential check failed");
|
|
51
|
-
});
|
|
52
|
+
}));
|
|
52
53
|
}
|
|
53
54
|
//#endregion
|
|
54
55
|
export { checkCredentialsAfterSetup, checkCredentialsAtBoot, loadCredentialProbe, reencryptWithCurrent, secretKeysOf };
|
package/dist/credentials.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"credentials.mjs","names":[],"sources":["../src/credentials.ts"],"sourcesContent":["/**\n * Runtime side of secret rotation: boot check/migration, the health probe, and\n * the re-encrypt action shared by the System page and the admin API.\n */\nimport type { DatabaseAdapter } from \"@storyshelf/core/adapter/database\";\nimport type { ShelfConfig } from \"@storyshelf/core/config\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport {\n probeCredentials,\n reencryptCredentials,\n type CredentialProbe,\n type ReencryptResult,\n} from \"@storyshelf/core/models\";\nimport type { SecretKeys } from \"@storyshelf/core/utils\";\nimport type
|
|
1
|
+
{"version":3,"file":"credentials.mjs","names":[],"sources":["../src/credentials.ts"],"sourcesContent":["/**\n * Runtime side of secret rotation: boot check/migration, the health probe, and\n * the re-encrypt action shared by the System page and the admin API.\n */\nimport type { DatabaseAdapter } from \"@storyshelf/core/adapter/database\";\nimport type { ShelfConfig } from \"@storyshelf/core/config\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport {\n probeCredentials,\n reencryptCredentials,\n type CredentialProbe,\n type ReencryptResult,\n} from \"@storyshelf/core/models\";\nimport type { SecretKeys } from \"@storyshelf/core/utils\";\nimport { trackBackground, type LifecycleCell } from \"./lifecycle.ts\";\n\n/** Current/previous key pair for a probe, or null when no secret is configured. */\nexport function secretKeysOf(config: ShelfConfig): SecretKeys | null {\n if (!config.secret) {\n return null;\n }\n return config.previousSecret\n ? { current: config.secret, previous: config.previousSecret }\n : { current: config.secret };\n}\n\n/** Probe stored credentials; null when no secret is configured. */\nexport async function loadCredentialProbe(\n db: DatabaseAdapter,\n config: ShelfConfig,\n): Promise<CredentialProbe | null> {\n const keys = secretKeysOf(config);\n return keys ? await probeCredentials(db, keys) : null;\n}\n\n/** Re-encrypt rows under `previousSecret`; throws when no rotation is configured. */\nexport async function reencryptWithCurrent(\n db: DatabaseAdapter,\n config: ShelfConfig,\n): Promise<ReencryptResult> {\n const keys = secretKeysOf(config);\n if (!keys?.previous) {\n throw new Error(\"No previousSecret is configured; nothing to re-encrypt\");\n }\n return await reencryptCredentials(db, keys);\n}\n\n/** Log what the probe found; returns whether rows still need re-encryption. */\nfunction logProbe(probe: CredentialProbe, config: ShelfConfig, logger: Logger): boolean {\n if (config.migrateCredentialsOnBoot && !config.previousSecret) {\n logger.warn(\"migrateCredentialsOnBoot has no effect without previousSecret\");\n }\n if (probe.unreadable.length > 0) {\n logger.error(\n { count: probe.unreadable.length, rows: probe.unreadable.slice(0, 20) },\n \"stored credentials cannot be decrypted; check SECRET and SECRET_PREVIOUS\",\n );\n }\n if (probe.previous > 0 && !config.migrateCredentialsOnBoot) {\n logger.warn(\n { count: probe.previous },\n \"credentials still encrypted with previousSecret; re-encrypt from the System page\",\n );\n }\n return probe.previous > 0;\n}\n\n/** Log the credential state at boot and, if opted in, migrate to the current key. */\nexport async function checkCredentialsAtBoot(\n db: DatabaseAdapter,\n config: ShelfConfig,\n logger: Logger,\n): Promise<void> {\n const probe = await loadCredentialProbe(db, config);\n if (!probe || !logProbe(probe, config, logger) || !config.migrateCredentialsOnBoot) {\n return;\n }\n const result = await reencryptWithCurrent(db, config);\n logger.info(\n { reencrypted: result.reencrypted, failed: result.failed },\n \"re-encrypted credentials with the current secret\",\n );\n}\n\n/** Run {@link checkCredentialsAtBoot} once adapters are ready; never blocks or throws. */\nexport function checkCredentialsAfterSetup(\n cell: LifecycleCell,\n db: DatabaseAdapter,\n config: ShelfConfig,\n logger: Logger,\n): void {\n trackBackground(\n cell,\n cell.ready\n .then(async (setup) => {\n if (setup.ok) {\n await checkCredentialsAtBoot(db, config, logger);\n }\n })\n .catch((error: unknown) => {\n logger.error({ err: error }, \"credential check failed\");\n }),\n );\n}\n"],"mappings":";;;;;;;AAiBA,SAAgB,aAAa,QAAwC;CACnE,IAAI,CAAC,OAAO,QACV,OAAO;CAET,OAAO,OAAO,iBACV;EAAE,SAAS,OAAO;EAAQ,UAAU,OAAO;CAAe,IAC1D,EAAE,SAAS,OAAO,OAAO;AAC/B;;AAGA,eAAsB,oBACpB,IACA,QACiC;CACjC,MAAM,OAAO,aAAa,MAAM;CAChC,OAAO,OAAO,MAAM,iBAAiB,IAAI,IAAI,IAAI;AACnD;;AAGA,eAAsB,qBACpB,IACA,QAC0B;CAC1B,MAAM,OAAO,aAAa,MAAM;CAChC,IAAI,CAAC,MAAM,UACT,MAAM,IAAI,MAAM,wDAAwD;CAE1E,OAAO,MAAM,qBAAqB,IAAI,IAAI;AAC5C;;AAGA,SAAS,SAAS,OAAwB,QAAqB,QAAyB;CACtF,IAAI,OAAO,4BAA4B,CAAC,OAAO,gBAC7C,OAAO,KAAK,+DAA+D;CAE7E,IAAI,MAAM,WAAW,SAAS,GAC5B,OAAO,MACL;EAAE,OAAO,MAAM,WAAW;EAAQ,MAAM,MAAM,WAAW,MAAM,GAAG,EAAE;CAAE,GACtE,0EACF;CAEF,IAAI,MAAM,WAAW,KAAK,CAAC,OAAO,0BAChC,OAAO,KACL,EAAE,OAAO,MAAM,SAAS,GACxB,kFACF;CAEF,OAAO,MAAM,WAAW;AAC1B;;AAGA,eAAsB,uBACpB,IACA,QACA,QACe;CACf,MAAM,QAAQ,MAAM,oBAAoB,IAAI,MAAM;CAClD,IAAI,CAAC,SAAS,CAAC,SAAS,OAAO,QAAQ,MAAM,KAAK,CAAC,OAAO,0BACxD;CAEF,MAAM,SAAS,MAAM,qBAAqB,IAAI,MAAM;CACpD,OAAO,KACL;EAAE,aAAa,OAAO;EAAa,QAAQ,OAAO;CAAO,GACzD,kDACF;AACF;;AAGA,SAAgB,2BACd,MACA,IACA,QACA,QACM;CACN,gBACE,MACA,KAAK,MACF,KAAK,OAAO,UAAU;EACrB,IAAI,MAAM,IACR,MAAM,uBAAuB,IAAI,QAAQ,MAAM;CAEnD,CAAC,CAAC,CACD,OAAO,UAAmB;EACzB,OAAO,MAAM,EAAE,KAAK,MAAM,GAAG,yBAAyB;CACxD,CAAC,CACL;AACF"}
|
package/dist/index.mjs
CHANGED
|
@@ -3,8 +3,8 @@ import "node:url";
|
|
|
3
3
|
import.meta.url;
|
|
4
4
|
import { createAuthSystemHook } from "./notify.mjs";
|
|
5
5
|
import { setupCaptureQueue } from "./capture-setup.mjs";
|
|
6
|
+
import { attachLifecycle, trackBackground } from "./lifecycle.mjs";
|
|
6
7
|
import { checkCredentialsAfterSetup } from "./credentials.mjs";
|
|
7
|
-
import { attachLifecycle } from "./lifecycle.mjs";
|
|
8
8
|
import { rateLimit, rateLimitKey } from "./middleware/rate-limit.mjs";
|
|
9
9
|
import { csrf } from "./middleware/csrf.mjs";
|
|
10
10
|
import { requestLogging } from "./middleware/request-log.mjs";
|
|
@@ -58,11 +58,11 @@ function instrumentedOptions(options) {
|
|
|
58
58
|
/** Once adapters are ready, requeue-once or fail builds a restart left `capturing`. */
|
|
59
59
|
function recoverAfterSetup(cell, recoverStuck, logger) {
|
|
60
60
|
if (!recoverStuck) return;
|
|
61
|
-
cell.ready.then(async (setup) => {
|
|
61
|
+
trackBackground(cell, cell.ready.then(async (setup) => {
|
|
62
62
|
if (setup.ok) await recoverStuck();
|
|
63
63
|
}).catch((error) => {
|
|
64
64
|
logger.error({ err: error }, "stuck capture recovery failed");
|
|
65
|
-
});
|
|
65
|
+
}));
|
|
66
66
|
}
|
|
67
67
|
/** Boot-time checks that run once adapters are ready: stuck-capture recovery and credential state. */
|
|
68
68
|
function runAfterSetup(cell, scoped, runtime, recoverStuck) {
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/index.tsx"],"sourcesContent":["import { swaggerUI } from \"@hono/swagger-ui\";\nimport { OpenAPIHono } from \"@hono/zod-openapi\";\nimport { AdapterLifecycleError, validateBootAssembly } from \"@storyshelf/core/adapter/setup\";\nimport type { ShelfConfig, ShelfOptions } from \"@storyshelf/core/config\";\n/**\n * StoryShelf app: compose database, storage, capture, auth, and git-host\n * adapters into a complete self-hosted visual-testing Hono app.\n */\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport {\n createHttpMiddleware,\n createInstrumentedDatabase,\n createInstrumentedStorage,\n} from \"@storyshelf/observability\";\nimport { requestId } from \"hono/request-id\";\nimport type { ShelfApp, ShelfContext, ShelfRouter } from \"./app-types.ts\";\nimport { setupCaptureQueue } from \"./capture-setup.ts\";\nimport { checkCredentialsAfterSetup } from \"./credentials.ts\";\nimport { attachLifecycle, type LifecycleCell } from \"./lifecycle.ts\";\nimport {\n authGate,\n csrf,\n rateLimit,\n rateLimitKey,\n requestLogging,\n resolveRequestUser,\n storeScope,\n} from \"./middleware/index.ts\";\nimport { serverTiming } from \"./middleware/server-timing.ts\";\nimport { setupGate, type MiddlewareWiring } from \"./middleware/setup-gate.ts\";\nimport { registerAdminPages } from \"./routers/admin-pages.ts\";\nimport { registerAdmin } from \"./routers/admin.ts\";\nimport { registerAssets } from \"./routers/assets.ts\";\nimport { registerEngineAuth } from \"./routers/auth.ts\";\nimport { registerBuilds } from \"./routers/builds.ts\";\nimport { registerHealth, type HealthDeps } from \"./routers/health.ts\";\nimport { registerLabels } from \"./routers/labels.ts\";\nimport { registerMedia } from \"./routers/media.ts\";\nimport { registerMembers } from \"./routers/members.ts\";\nimport {\n registerNotificationChannels,\n registerSystemChannels,\n} from \"./routers/notification-channels.ts\";\nimport { registerNotificationSubscriptions } from \"./routers/notification-subscriptions.ts\";\nimport { registerProfile } from \"./routers/profile.ts\";\nimport { registerProjects } from \"./routers/projects.ts\";\nimport { registerStatusConfigs } from \"./routers/status-configs.ts\";\nimport { registerStorybook } from \"./routers/storybook.ts\";\nimport { registerTokens } from \"./routers/tokens.ts\";\nimport { registerUiPages } from \"./routers/ui.ts\";\nimport { registerWebhooks } from \"./routers/webhooks.ts\";\nimport { registerWellKnown } from \"./routers/well-known.ts\";\nimport { resolveRuntime } from \"./runtime.ts\";\n\n/**\n * Fail fast on third-party adapters that cannot satisfy their slot.\n * Throws before any route or lifecycle hook runs.\n */\nfunction throwOnInvalidAdapters(options: ShelfOptions): void {\n const invalid = validateBootAssembly(options);\n if (invalid.length > 0) {\n throw new AdapterLifecycleError(\"setup\", invalid);\n }\n}\n\n/** Request/capture-path adapters with OTEL wrappers (lifecycle keeps raw ones). */\nfunction instrumentedOptions(options: ShelfOptions): ShelfOptions {\n return {\n ...options,\n database: createInstrumentedDatabase(options.database),\n storage: createInstrumentedStorage(options.storage),\n };\n}\n\n/** Once adapters are ready, requeue-once or fail builds a restart left `capturing`. */\nfunction recoverAfterSetup(\n cell: LifecycleCell,\n recoverStuck: (() => Promise<unknown>) | undefined,\n logger: Logger,\n): void {\n if (!recoverStuck) {\n return;\n }\n cell.ready\n .then(async (setup) => {\n if (setup.ok) {\n await recoverStuck();\n }\n })\n .catch((error: unknown) => {\n logger.error({ err: error }, \"stuck capture recovery failed\");\n });\n}\n\n/** Boot-time checks that run once adapters are ready: stuck-capture recovery and credential state. */\nfunction runAfterSetup(\n cell: LifecycleCell,\n scoped: ShelfOptions,\n runtime: ReturnType<typeof resolveRuntime>,\n recoverStuck: (() => Promise<unknown>) | undefined,\n): void {\n recoverAfterSetup(cell, recoverStuck, runtime.logger);\n checkCredentialsAfterSetup(cell, scoped.database, runtime.config, runtime.logger);\n}\n\n/** Attach lifecycle, queue, middleware, routes, and docs to the app. */\nfunction assembleApp(app: ShelfApp, options: ShelfOptions, scoped: ShelfOptions): void {\n const runtime = resolveRuntime(options);\n const cell: LifecycleCell = { ready: Promise.resolve({ ok: true, failures: [] }), settled: null };\n attachLifecycle(app, options, runtime, cell);\n if (runtime.config.serverTiming === true) {\n app.use(\"*\", serverTiming(true));\n }\n const { queue, enqueueCapture, recoverStuck } = setupCaptureQueue(\n scoped,\n runtime.config,\n runtime.gitHosts,\n runtime.logger,\n );\n runAfterSetup(cell, scoped, runtime, recoverStuck);\n wireMiddleware(app, {\n ...runtime,\n queue,\n enqueueCapture,\n options: scoped,\n getReady: async () => await cell.ready,\n });\n registerAllRoutes(app, options, {\n sources: options,\n getSettled: () => cell.settled,\n bootTimeMs: Date.now(),\n version: packageVersion(),\n });\n registerDocs(app);\n}\n\n/**\n * Create the StoryShelf Hono app with all API routes and HTML pages.\n *\n * Adapter `init` hooks kick off eagerly in the background (the constructor\n * stays synchronous). Await `app.lifecycle.setup()` before serving for\n * fail-fast startup, or let the first request gate on settlement.\n */\nexport function createShelfApp(options: ShelfOptions): ShelfApp {\n throwOnInvalidAdapters(options);\n const app = new OpenAPIHono<{ Variables: ShelfContext }>() as ShelfApp;\n assembleApp(app, options, instrumentedOptions(options));\n\n return app;\n}\n\n/** Cookie-authenticated write paths guarded by CSRF tokens. */\nconst CSRF_PATHS = [\n \"/auth/logout\",\n \"/auth/invites/*\",\n \"/projects/:slug/settings/*\",\n \"/profile/*\",\n \"/profile\",\n \"/admin/credentials/*\",\n] as const;\n\n/** Attach global middleware: ids, logging, init gate, limits, store scope, auth gate. */\nfunction wireMiddleware(app: ShelfRouter, wiring: MiddlewareWiring): void {\n const { config, logger, getReady } = wiring;\n app.use(\"*\", requestId());\n // OTEL server spans (W3C extraction, route-template naming). Mounted right\n // after requestId so `storyshelf.req_id` resolves; `enduser.id` resolves\n // once storeScope runs downstream. Noop without an SDK.\n app.use(\n \"*\",\n createHttpMiddleware({ serviceName: \"storyshelf\", serviceVersion: packageVersion() }),\n );\n // Structured request logging. Uses a Hono-native middleware rather than\n // Pino-http, which expects a Node server response (`res.on`) incompatible\n // With Hono's Web `Request`/`Response` model.\n app.use(\"*\", requestLogging(logger));\n app.use(\"*\", setupGate(getReady));\n mountGuards(app, config);\n mountNotificationLimits(app);\n mountStoreScope(app, wiring);\n app.use(\"*\", authGate());\n}\n\n/** Mount rate limits and CSRF guards (abuse floors, not strict protection). */\nfunction mountGuards(app: ShelfRouter, config: ShelfConfig): void {\n app.use(\"/api/v1/*\", rateLimit({ windowMs: 60_000, max: 100 }));\n app.use(\"/api/v1/tokens/*\", rateLimit({ windowMs: 60_000, max: 10 }));\n app.use(\"/api/v1/webhooks/*\", rateLimit({ windowMs: 60_000, max: 20 }));\n // Auth endpoints ride their own buckets (the shared store keys by IP, so\n // prefix the key: login floods must not eat the API budget or vice versa).\n app.use(\"/auth/*\", rateLimit({ windowMs: 60_000, max: 100, keyGenerator: rateLimitKey(\"auth\") }));\n app.use(\n \"/api/auth/*\",\n rateLimit({ windowMs: 60_000, max: 300, keyGenerator: rateLimitKey(\"engine\") }),\n );\n for (const path of CSRF_PATHS) {\n app.use(path, csrf(config.secret));\n }\n}\n\n/** Abuse floors for notification management endpoints (own counters). */\nfunction mountNotificationLimits(app: ShelfRouter): void {\n const channel = { windowMs: 60_000, max: 20, keyGenerator: rateLimitKey(\"notify-channels\") };\n const admin = { windowMs: 60_000, max: 20, keyGenerator: rateLimitKey(\"notify-admin\") };\n const subscription = { windowMs: 60_000, max: 30, keyGenerator: rateLimitKey(\"notify-subs\") };\n app.use(\"/api/v1/projects/:slug/notification-channels\", rateLimit(channel));\n app.use(\"/api/v1/projects/:slug/notification-channels/:channelId\", rateLimit(channel));\n app.use(\"/api/v1/projects/:slug/notifications\", rateLimit(subscription));\n app.use(\"/api/v1/projects/:slug/notifications/me\", rateLimit(subscription));\n app.use(\"/api/v1/admin/notification-channels\", rateLimit(admin));\n app.use(\"/api/v1/admin/notification-channels/:channelId\", rateLimit(admin));\n}\n\n/** Mount the request-scoped store (db/storage/config/user/adapters). */\nfunction mountStoreScope(app: ShelfRouter, wiring: MiddlewareWiring): void {\n const { options, config, ui, logger, authEnabled, enqueueCapture, queue, gitHosts, notifiers } =\n wiring;\n app.use(\n \"*\",\n storeScope({\n db: options.database,\n storage: options.storage,\n config,\n ui,\n logger,\n authEnabled,\n enqueueCapture,\n captureQueue: queue,\n gitHosts,\n notifiers,\n resolveUser: async (c) => await resolveRequestUser(c, options.auth),\n }),\n );\n}\n\n/** Register every JSON API router. */\nfunction registerApiRoutes(app: ShelfRouter, health: HealthDeps): void {\n registerProjectRoutes(app);\n registerNotificationRoutes(app);\n registerStatusConfigs(app);\n registerHealth(app, health);\n registerAdmin(app);\n}\n\n/** Register the per-project resource routers. */\nfunction registerProjectRoutes(app: ShelfRouter): void {\n registerProjects(app);\n registerBuilds(app);\n registerLabels(app);\n registerMedia(app);\n registerMembers(app);\n registerTokens(app);\n registerWebhooks(app);\n}\n\n/** Register the notification channel and subscription routers. */\nfunction registerNotificationRoutes(app: ShelfRouter): void {\n registerNotificationChannels(app);\n registerNotificationSubscriptions(app);\n registerSystemChannels(app);\n}\n\n/** Register the auth flow: engine mount, relying-party documents, profile. */\nfunction registerAuthFlow(app: ShelfRouter, options: ShelfOptions): void {\n // Auth-gated pages mount only with auth: authEnabled=false mounts zero\n // auth routes. Static imports stay — true lazy loading would need an async\n // createShelfApp, which the sync Hono assembly cannot do.\n if (!options.auth) {\n return;\n }\n registerEngineAuth(app, options.auth);\n registerWellKnown(app, options.auth);\n registerProfile(app, options.auth);\n}\n\n/** Register HTML pages, assets, and the optional auth flow. */\nfunction registerPageRoutes(app: ShelfRouter, options: ShelfOptions, health: HealthDeps): void {\n registerAuthFlow(app, options);\n registerAssets(app);\n registerStorybook(app);\n registerAdminPages(app, health, options.auth);\n registerUiPages(app);\n}\n\n/** Register every API router, page set, and optional auth flow. */\nfunction registerAllRoutes(app: ShelfRouter, options: ShelfOptions, health: HealthDeps): void {\n registerApiRoutes(app, health);\n registerPageRoutes(app, options, health);\n}\n\n/** Register the OpenAPI document and interactive docs page. */\nfunction registerDocs(app: ShelfRouter): void {\n app.doc(\"/api/v1/openapi.json\", {\n openapi: \"3.0.0\",\n info: {\n title: \"StoryShelf API\",\n description:\n \"REST API for the StoryShelf visual testing platform. JSON endpoints live under /api/v1; HTML pages are served at /.\",\n version: \"1.0.0\",\n },\n tags: [\n {\n name: \"Projects\",\n description: \"Create and manage projects (one project is one Storybook).\",\n },\n {\n name: \"Builds\",\n description: \"Upload Storybook builds, inspect capture attempts, retry, and delete.\",\n },\n {\n name: \"Review\",\n description: \"Approve or reject snapshots, bulk actions, re-diff, and review comments.\",\n },\n { name: \"Labels\", description: \"Typed build labels and their link templates.\" },\n {\n name: \"Members\",\n description: \"Project members, roles, and identity-provider group mappings.\",\n },\n { name: \"Tokens\", description: \"API tokens used by the CLI and CI.\" },\n { name: \"Webhooks\", description: \"Signed webhook deliveries for build events.\" },\n {\n name: \"Notifications\",\n description: \"Email and chat notification channels and subscriptions.\",\n },\n { name: \"Git status\", description: \"Commit-status integrations for GitHub and GitLab.\" },\n { name: \"Admin\", description: \"Site-wide administration.\" },\n ],\n });\n app.get(\"/api/v1/docs\", swaggerUI({ url: \"/api/v1/openapi.json\" }));\n}\n\n/** Package version injected at build via __PKG_VERSION__. */\nfunction packageVersion(): string {\n return (globalThis as unknown as { __PKG_VERSION__?: string }).__PKG_VERSION__ ?? \"0.0.0\";\n}\n\n/**\n * Public surface: the app and its types only.\n *\n * `createShelfApp` plus the option/config types needed to call it. Adapter\n * interfaces live under `core/adapter/*`; runtime helpers under `core/logger`,\n * `core/capture`, `core/paths`, `core/urls`, `core/diff`; tables and row types\n * under `core/schema`. Importing the barrel must never pull Node-only modules\n * into edge bundles beyond what Hono itself needs.\n */\nexport { createAuthSystemHook } from \"./notify.ts\";\nexport type {\n ShelfOptions,\n ShelfConfig,\n UIConfig,\n BrandTheme,\n NotificationsConfig,\n} from \"@storyshelf/core/config\";\nexport {\n createShelfLogger,\n type Logger,\n type LoggerOptions,\n type PinoTransport,\n} from \"@storyshelf/core/logger\";\nexport type { ShelfApp, ShelfContext, ShelfLifecycle, ShelfRouter } from \"./app-types.ts\";\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/index.tsx"],"sourcesContent":["import { swaggerUI } from \"@hono/swagger-ui\";\nimport { OpenAPIHono } from \"@hono/zod-openapi\";\nimport { AdapterLifecycleError, validateBootAssembly } from \"@storyshelf/core/adapter/setup\";\nimport type { ShelfConfig, ShelfOptions } from \"@storyshelf/core/config\";\n/**\n * StoryShelf app: compose database, storage, capture, auth, and git-host\n * adapters into a complete self-hosted visual-testing Hono app.\n */\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport {\n createHttpMiddleware,\n createInstrumentedDatabase,\n createInstrumentedStorage,\n} from \"@storyshelf/observability\";\nimport { requestId } from \"hono/request-id\";\nimport type { ShelfApp, ShelfContext, ShelfRouter } from \"./app-types.ts\";\nimport { setupCaptureQueue } from \"./capture-setup.ts\";\nimport { checkCredentialsAfterSetup } from \"./credentials.ts\";\nimport { attachLifecycle, trackBackground, type LifecycleCell } from \"./lifecycle.ts\";\nimport {\n authGate,\n csrf,\n rateLimit,\n rateLimitKey,\n requestLogging,\n resolveRequestUser,\n storeScope,\n} from \"./middleware/index.ts\";\nimport { serverTiming } from \"./middleware/server-timing.ts\";\nimport { setupGate, type MiddlewareWiring } from \"./middleware/setup-gate.ts\";\nimport { registerAdminPages } from \"./routers/admin-pages.ts\";\nimport { registerAdmin } from \"./routers/admin.ts\";\nimport { registerAssets } from \"./routers/assets.ts\";\nimport { registerEngineAuth } from \"./routers/auth.ts\";\nimport { registerBuilds } from \"./routers/builds.ts\";\nimport { registerHealth, type HealthDeps } from \"./routers/health.ts\";\nimport { registerLabels } from \"./routers/labels.ts\";\nimport { registerMedia } from \"./routers/media.ts\";\nimport { registerMembers } from \"./routers/members.ts\";\nimport {\n registerNotificationChannels,\n registerSystemChannels,\n} from \"./routers/notification-channels.ts\";\nimport { registerNotificationSubscriptions } from \"./routers/notification-subscriptions.ts\";\nimport { registerProfile } from \"./routers/profile.ts\";\nimport { registerProjects } from \"./routers/projects.ts\";\nimport { registerStatusConfigs } from \"./routers/status-configs.ts\";\nimport { registerStorybook } from \"./routers/storybook.ts\";\nimport { registerTokens } from \"./routers/tokens.ts\";\nimport { registerUiPages } from \"./routers/ui.ts\";\nimport { registerWebhooks } from \"./routers/webhooks.ts\";\nimport { registerWellKnown } from \"./routers/well-known.ts\";\nimport { resolveRuntime } from \"./runtime.ts\";\n\n/**\n * Fail fast on third-party adapters that cannot satisfy their slot.\n * Throws before any route or lifecycle hook runs.\n */\nfunction throwOnInvalidAdapters(options: ShelfOptions): void {\n const invalid = validateBootAssembly(options);\n if (invalid.length > 0) {\n throw new AdapterLifecycleError(\"setup\", invalid);\n }\n}\n\n/** Request/capture-path adapters with OTEL wrappers (lifecycle keeps raw ones). */\nfunction instrumentedOptions(options: ShelfOptions): ShelfOptions {\n return {\n ...options,\n database: createInstrumentedDatabase(options.database),\n storage: createInstrumentedStorage(options.storage),\n };\n}\n\n/** Once adapters are ready, requeue-once or fail builds a restart left `capturing`. */\nfunction recoverAfterSetup(\n cell: LifecycleCell,\n recoverStuck: (() => Promise<unknown>) | undefined,\n logger: Logger,\n): void {\n if (!recoverStuck) {\n return;\n }\n trackBackground(\n cell,\n cell.ready\n .then(async (setup) => {\n if (setup.ok) {\n await recoverStuck();\n }\n })\n .catch((error: unknown) => {\n logger.error({ err: error }, \"stuck capture recovery failed\");\n }),\n );\n}\n\n/** Boot-time checks that run once adapters are ready: stuck-capture recovery and credential state. */\nfunction runAfterSetup(\n cell: LifecycleCell,\n scoped: ShelfOptions,\n runtime: ReturnType<typeof resolveRuntime>,\n recoverStuck: (() => Promise<unknown>) | undefined,\n): void {\n recoverAfterSetup(cell, recoverStuck, runtime.logger);\n checkCredentialsAfterSetup(cell, scoped.database, runtime.config, runtime.logger);\n}\n\n/** Attach lifecycle, queue, middleware, routes, and docs to the app. */\nfunction assembleApp(app: ShelfApp, options: ShelfOptions, scoped: ShelfOptions): void {\n const runtime = resolveRuntime(options);\n const cell: LifecycleCell = { ready: Promise.resolve({ ok: true, failures: [] }), settled: null };\n attachLifecycle(app, options, runtime, cell);\n if (runtime.config.serverTiming === true) {\n app.use(\"*\", serverTiming(true));\n }\n const { queue, enqueueCapture, recoverStuck } = setupCaptureQueue(\n scoped,\n runtime.config,\n runtime.gitHosts,\n runtime.logger,\n );\n runAfterSetup(cell, scoped, runtime, recoverStuck);\n wireMiddleware(app, {\n ...runtime,\n queue,\n enqueueCapture,\n options: scoped,\n getReady: async () => await cell.ready,\n });\n registerAllRoutes(app, options, {\n sources: options,\n getSettled: () => cell.settled,\n bootTimeMs: Date.now(),\n version: packageVersion(),\n });\n registerDocs(app);\n}\n\n/**\n * Create the StoryShelf Hono app with all API routes and HTML pages.\n *\n * Adapter `init` hooks kick off eagerly in the background (the constructor\n * stays synchronous). Await `app.lifecycle.setup()` before serving for\n * fail-fast startup, or let the first request gate on settlement.\n */\nexport function createShelfApp(options: ShelfOptions): ShelfApp {\n throwOnInvalidAdapters(options);\n const app = new OpenAPIHono<{ Variables: ShelfContext }>() as ShelfApp;\n assembleApp(app, options, instrumentedOptions(options));\n\n return app;\n}\n\n/** Cookie-authenticated write paths guarded by CSRF tokens. */\nconst CSRF_PATHS = [\n \"/auth/logout\",\n \"/auth/invites/*\",\n \"/projects/:slug/settings/*\",\n \"/profile/*\",\n \"/profile\",\n \"/admin/credentials/*\",\n] as const;\n\n/** Attach global middleware: ids, logging, init gate, limits, store scope, auth gate. */\nfunction wireMiddleware(app: ShelfRouter, wiring: MiddlewareWiring): void {\n const { config, logger, getReady } = wiring;\n app.use(\"*\", requestId());\n // OTEL server spans (W3C extraction, route-template naming). Mounted right\n // after requestId so `storyshelf.req_id` resolves; `enduser.id` resolves\n // once storeScope runs downstream. Noop without an SDK.\n app.use(\n \"*\",\n createHttpMiddleware({ serviceName: \"storyshelf\", serviceVersion: packageVersion() }),\n );\n // Structured request logging. Uses a Hono-native middleware rather than\n // Pino-http, which expects a Node server response (`res.on`) incompatible\n // With Hono's Web `Request`/`Response` model.\n app.use(\"*\", requestLogging(logger));\n app.use(\"*\", setupGate(getReady));\n mountGuards(app, config);\n mountNotificationLimits(app);\n mountStoreScope(app, wiring);\n app.use(\"*\", authGate());\n}\n\n/** Mount rate limits and CSRF guards (abuse floors, not strict protection). */\nfunction mountGuards(app: ShelfRouter, config: ShelfConfig): void {\n app.use(\"/api/v1/*\", rateLimit({ windowMs: 60_000, max: 100 }));\n app.use(\"/api/v1/tokens/*\", rateLimit({ windowMs: 60_000, max: 10 }));\n app.use(\"/api/v1/webhooks/*\", rateLimit({ windowMs: 60_000, max: 20 }));\n // Auth endpoints ride their own buckets (the shared store keys by IP, so\n // prefix the key: login floods must not eat the API budget or vice versa).\n app.use(\"/auth/*\", rateLimit({ windowMs: 60_000, max: 100, keyGenerator: rateLimitKey(\"auth\") }));\n app.use(\n \"/api/auth/*\",\n rateLimit({ windowMs: 60_000, max: 300, keyGenerator: rateLimitKey(\"engine\") }),\n );\n for (const path of CSRF_PATHS) {\n app.use(path, csrf(config.secret));\n }\n}\n\n/** Abuse floors for notification management endpoints (own counters). */\nfunction mountNotificationLimits(app: ShelfRouter): void {\n const channel = { windowMs: 60_000, max: 20, keyGenerator: rateLimitKey(\"notify-channels\") };\n const admin = { windowMs: 60_000, max: 20, keyGenerator: rateLimitKey(\"notify-admin\") };\n const subscription = { windowMs: 60_000, max: 30, keyGenerator: rateLimitKey(\"notify-subs\") };\n app.use(\"/api/v1/projects/:slug/notification-channels\", rateLimit(channel));\n app.use(\"/api/v1/projects/:slug/notification-channels/:channelId\", rateLimit(channel));\n app.use(\"/api/v1/projects/:slug/notifications\", rateLimit(subscription));\n app.use(\"/api/v1/projects/:slug/notifications/me\", rateLimit(subscription));\n app.use(\"/api/v1/admin/notification-channels\", rateLimit(admin));\n app.use(\"/api/v1/admin/notification-channels/:channelId\", rateLimit(admin));\n}\n\n/** Mount the request-scoped store (db/storage/config/user/adapters). */\nfunction mountStoreScope(app: ShelfRouter, wiring: MiddlewareWiring): void {\n const { options, config, ui, logger, authEnabled, enqueueCapture, queue, gitHosts, notifiers } =\n wiring;\n app.use(\n \"*\",\n storeScope({\n db: options.database,\n storage: options.storage,\n config,\n ui,\n logger,\n authEnabled,\n enqueueCapture,\n captureQueue: queue,\n gitHosts,\n notifiers,\n resolveUser: async (c) => await resolveRequestUser(c, options.auth),\n }),\n );\n}\n\n/** Register every JSON API router. */\nfunction registerApiRoutes(app: ShelfRouter, health: HealthDeps): void {\n registerProjectRoutes(app);\n registerNotificationRoutes(app);\n registerStatusConfigs(app);\n registerHealth(app, health);\n registerAdmin(app);\n}\n\n/** Register the per-project resource routers. */\nfunction registerProjectRoutes(app: ShelfRouter): void {\n registerProjects(app);\n registerBuilds(app);\n registerLabels(app);\n registerMedia(app);\n registerMembers(app);\n registerTokens(app);\n registerWebhooks(app);\n}\n\n/** Register the notification channel and subscription routers. */\nfunction registerNotificationRoutes(app: ShelfRouter): void {\n registerNotificationChannels(app);\n registerNotificationSubscriptions(app);\n registerSystemChannels(app);\n}\n\n/** Register the auth flow: engine mount, relying-party documents, profile. */\nfunction registerAuthFlow(app: ShelfRouter, options: ShelfOptions): void {\n // Auth-gated pages mount only with auth: authEnabled=false mounts zero\n // auth routes. Static imports stay — true lazy loading would need an async\n // createShelfApp, which the sync Hono assembly cannot do.\n if (!options.auth) {\n return;\n }\n registerEngineAuth(app, options.auth);\n registerWellKnown(app, options.auth);\n registerProfile(app, options.auth);\n}\n\n/** Register HTML pages, assets, and the optional auth flow. */\nfunction registerPageRoutes(app: ShelfRouter, options: ShelfOptions, health: HealthDeps): void {\n registerAuthFlow(app, options);\n registerAssets(app);\n registerStorybook(app);\n registerAdminPages(app, health, options.auth);\n registerUiPages(app);\n}\n\n/** Register every API router, page set, and optional auth flow. */\nfunction registerAllRoutes(app: ShelfRouter, options: ShelfOptions, health: HealthDeps): void {\n registerApiRoutes(app, health);\n registerPageRoutes(app, options, health);\n}\n\n/** Register the OpenAPI document and interactive docs page. */\nfunction registerDocs(app: ShelfRouter): void {\n app.doc(\"/api/v1/openapi.json\", {\n openapi: \"3.0.0\",\n info: {\n title: \"StoryShelf API\",\n description:\n \"REST API for the StoryShelf visual testing platform. JSON endpoints live under /api/v1; HTML pages are served at /.\",\n version: \"1.0.0\",\n },\n tags: [\n {\n name: \"Projects\",\n description: \"Create and manage projects (one project is one Storybook).\",\n },\n {\n name: \"Builds\",\n description: \"Upload Storybook builds, inspect capture attempts, retry, and delete.\",\n },\n {\n name: \"Review\",\n description: \"Approve or reject snapshots, bulk actions, re-diff, and review comments.\",\n },\n { name: \"Labels\", description: \"Typed build labels and their link templates.\" },\n {\n name: \"Members\",\n description: \"Project members, roles, and identity-provider group mappings.\",\n },\n { name: \"Tokens\", description: \"API tokens used by the CLI and CI.\" },\n { name: \"Webhooks\", description: \"Signed webhook deliveries for build events.\" },\n {\n name: \"Notifications\",\n description: \"Email and chat notification channels and subscriptions.\",\n },\n { name: \"Git status\", description: \"Commit-status integrations for GitHub and GitLab.\" },\n { name: \"Admin\", description: \"Site-wide administration.\" },\n ],\n });\n app.get(\"/api/v1/docs\", swaggerUI({ url: \"/api/v1/openapi.json\" }));\n}\n\n/** Package version injected at build via __PKG_VERSION__. */\nfunction packageVersion(): string {\n return (globalThis as unknown as { __PKG_VERSION__?: string }).__PKG_VERSION__ ?? \"0.0.0\";\n}\n\n/**\n * Public surface: the app and its types only.\n *\n * `createShelfApp` plus the option/config types needed to call it. Adapter\n * interfaces live under `core/adapter/*`; runtime helpers under `core/logger`,\n * `core/capture`, `core/paths`, `core/urls`, `core/diff`; tables and row types\n * under `core/schema`. Importing the barrel must never pull Node-only modules\n * into edge bundles beyond what Hono itself needs.\n */\nexport { createAuthSystemHook } from \"./notify.ts\";\nexport type {\n ShelfOptions,\n ShelfConfig,\n UIConfig,\n BrandTheme,\n NotificationsConfig,\n} from \"@storyshelf/core/config\";\nexport {\n createShelfLogger,\n type Logger,\n type LoggerOptions,\n type PinoTransport,\n} from \"@storyshelf/core/logger\";\nexport type { ShelfApp, ShelfContext, ShelfLifecycle, ShelfRouter } from \"./app-types.ts\";\n"],"mappings":"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"}
|
package/dist/lifecycle.mjs
CHANGED
|
@@ -5,6 +5,23 @@ import { startBranchGcTimer } from "./retention-timer.mjs";
|
|
|
5
5
|
import { AdapterLifecycleError, bindAdapterLoggers, collectSetups, collectTeardowns, runAdapterSetups, runAdapterTeardowns, validateBootAssembly } from "@storyshelf/core/adapter/setup";
|
|
6
6
|
import { sanitizeErrorText } from "@storyshelf/core/utils";
|
|
7
7
|
//#region src/lifecycle.ts
|
|
8
|
+
/** Longest teardown waits for background boot tasks before closing adapters anyway. */
|
|
9
|
+
const BACKGROUND_DRAIN_MS = 5e3;
|
|
10
|
+
/** Register a boot task so `teardown()` does not close adapters underneath it. */
|
|
11
|
+
function trackBackground(cell, task) {
|
|
12
|
+
(cell.background ??= []).push(task);
|
|
13
|
+
}
|
|
14
|
+
/** Wait for tracked boot tasks, bounded so a hung task can never block shutdown. */
|
|
15
|
+
async function drainBackground(cell) {
|
|
16
|
+
const pending = Promise.allSettled(cell.background ?? []);
|
|
17
|
+
let timer;
|
|
18
|
+
const timeout = new Promise((resolve) => {
|
|
19
|
+
timer = setTimeout(resolve, cell.drainMs ?? BACKGROUND_DRAIN_MS);
|
|
20
|
+
timer.unref();
|
|
21
|
+
});
|
|
22
|
+
await Promise.race([pending, timeout]);
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
}
|
|
8
25
|
function trackSettlement(cell, promise) {
|
|
9
26
|
promise.then((result) => {
|
|
10
27
|
cell.settled = result;
|
|
@@ -118,6 +135,7 @@ function createLifecycle(options, ctx, logger, cell, timer = null) {
|
|
|
118
135
|
},
|
|
119
136
|
teardown: async () => {
|
|
120
137
|
timer?.stop();
|
|
138
|
+
await drainBackground(cell);
|
|
121
139
|
const result = await runAdapterTeardowns(collectTeardowns(options), ctx, logger);
|
|
122
140
|
await options.observability?.shutdown().catch((error) => {
|
|
123
141
|
logger.error({ err: error }, "observability shutdown failed");
|
|
@@ -127,6 +145,6 @@ function createLifecycle(options, ctx, logger, cell, timer = null) {
|
|
|
127
145
|
};
|
|
128
146
|
}
|
|
129
147
|
//#endregion
|
|
130
|
-
export { attachLifecycle };
|
|
148
|
+
export { attachLifecycle, trackBackground };
|
|
131
149
|
|
|
132
150
|
//# sourceMappingURL=lifecycle.mjs.map
|
package/dist/lifecycle.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lifecycle.mjs","names":[],"sources":["../src/lifecycle.ts"],"sourcesContent":["import type { AdapterSetupContext } from \"@storyshelf/core/adapter/metadata\";\nimport {\n AdapterLifecycleError,\n bindAdapterLoggers,\n collectSetups,\n collectTeardowns,\n runAdapterSetups,\n runAdapterTeardowns,\n validateBootAssembly,\n} from \"@storyshelf/core/adapter/setup\";\nimport type { AdapterSetupResult } from \"@storyshelf/core/adapter/setup\";\nimport type { ShelfOptions } from \"@storyshelf/core/config\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport { sanitizeErrorText } from \"@storyshelf/core/utils\";\nimport type { ShelfRouter, ShelfLifecycle } from \"./app-types.ts\";\nimport { startBranchGcTimer } from \"./retention-timer.ts\";\nimport type { ServerRuntime } from \"./runtime.ts\";\n\n/** Mutable setup-settlement cell shared by the gate, health, and `setup()`. */\nexport interface LifecycleCell {\n ready: Promise<AdapterSetupResult>;\n settled: AdapterSetupResult | null;\n}\n\nfunction trackSettlement(cell: LifecycleCell, promise: Promise<AdapterSetupResult>): void {\n promise.then(\n (result) => {\n cell.settled = result;\n },\n () => {\n cell.settled = { ok: false, failures: [] };\n },\n );\n}\n\n/** Kick the eager background setup run (resolves; never rejects). */\nfunction kickSetup(\n options: ShelfOptions,\n ctx: AdapterSetupContext,\n logger: Logger,\n cell: LifecycleCell,\n): void {\n // Auth is not an adapter: its one-shot boot validation runs first, then\n // the adapter setups. A failing secret check fails readiness, like others.\n cell.ready = (async (): Promise<AdapterSetupResult> => {\n const invalid = validateBootAssembly(options);\n if (invalid.length > 0) {\n logger.error({ failures: invalid }, \"adapter validation failed\");\n return { ok: false, failures: invalid };\n }\n const authFailed = await runAuthSetup(options);\n if (authFailed) {\n logger.error({ failures: authFailed.failures }, \"auth setup failed\");\n return authFailed;\n }\n return await runAdapterSetups(collectSetups(options), ctx, logger);\n })();\n trackSettlement(cell, cell.ready);\n}\n\n/** Login method count, or null when the engine throws during introspection. */\nfunction loginMethodCount(auth: NonNullable<ShelfOptions[\"auth\"]>): number | null {\n try {\n return auth.loginMethods().length;\n } catch {\n return null;\n }\n}\n\n/** Log which auth mode the server boots with (open servers stay visible). */\nfunction logAuthMode(options: ShelfOptions, logger: Logger): void {\n if (!options.auth) {\n logger.info(\"auth disabled (local default; every route is open)\");\n return;\n }\n const methods = loginMethodCount(options.auth);\n if (methods === null) {\n logger.warn(\"auth loginMethods() threw during boot logging\");\n return;\n }\n logger.info({ loginMethods: methods }, \"auth enabled\");\n}\n\n/** Kick eager setup and attach the `app.lifecycle` namespace (single run). */\nexport function attachLifecycle(\n app: ShelfRouter,\n options: ShelfOptions,\n runtime: ServerRuntime,\n cell: LifecycleCell,\n): void {\n const setupCtx: AdapterSetupContext = { config: runtime.config, logger: runtime.logger };\n bindAdapterLoggers(options, runtime.logger);\n logAuthMode(options, runtime.logger);\n kickSetup(options, setupCtx, runtime.logger, cell);\n const timer = startBranchGcInterval(options, runtime);\n Object.assign(app, {\n lifecycle: createLifecycle(options, setupCtx, runtime.logger, cell, timer),\n });\n}\n\nfunction startBranchGcInterval(\n options: ShelfOptions,\n runtime: ServerRuntime,\n): { stop(): void } | null {\n return startBranchGcTimer(options.database, options.storage, runtime.config, runtime.logger);\n}\n\n/** Run auth boot validation, returning a failure result instead of throwing. */\nasync function runAuthSetup(options: ShelfOptions): Promise<AdapterSetupResult | null> {\n try {\n await options.auth?.setup?.();\n return null;\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n return {\n ok: false,\n failures: [\n { category: \"auth\", kind: \"auth\", name: \"auth\", error: sanitizeErrorText(message, 500) },\n ],\n };\n }\n}\n/** Validation failure settled on the cell — null when the assembly is sound. */\nfunction invalidSources(options: ShelfOptions, cell: LifecycleCell): AdapterSetupResult | null {\n const invalid = validateBootAssembly(options);\n if (invalid.length === 0) {\n return null;\n }\n const result: AdapterSetupResult = { ok: false, failures: invalid };\n cell.ready = Promise.resolve(result);\n cell.settled = result;\n return result;\n}\n/** Build the `app.lifecycle` namespace closing over one settlement cell. */\nfunction createLifecycle(\n options: ShelfOptions,\n ctx: AdapterSetupContext,\n logger: Logger,\n cell: LifecycleCell,\n timer: { stop(): void } | null = null,\n): ShelfLifecycle {\n return {\n get ready() {\n return cell.ready;\n },\n logger,\n setup: async () => {\n const blocked = invalidSources(options, cell);\n if (blocked) {\n throw new AdapterLifecycleError(\"setup\", blocked.failures);\n }\n const result = await runAdapterSetups(collectSetups(options), ctx, logger);\n cell.ready = Promise.resolve(result);\n cell.settled = result;\n if (!result.ok) {\n throw new AdapterLifecycleError(\"setup\", result.failures);\n }\n },\n teardown: async () => {\n timer?.stop();\n const result = await runAdapterTeardowns(collectTeardowns(options), ctx, logger);\n await options.observability?.shutdown().catch((error: unknown) => {\n logger.error({ err: error }, \"observability shutdown failed\");\n });\n if (!result.ok) {\n throw new AdapterLifecycleError(\"teardown\", result.failures);\n }\n },\n };\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"file":"lifecycle.mjs","names":[],"sources":["../src/lifecycle.ts"],"sourcesContent":["import type { AdapterSetupContext } from \"@storyshelf/core/adapter/metadata\";\nimport {\n AdapterLifecycleError,\n bindAdapterLoggers,\n collectSetups,\n collectTeardowns,\n runAdapterSetups,\n runAdapterTeardowns,\n validateBootAssembly,\n} from \"@storyshelf/core/adapter/setup\";\nimport type { AdapterSetupResult } from \"@storyshelf/core/adapter/setup\";\nimport type { ShelfOptions } from \"@storyshelf/core/config\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport { sanitizeErrorText } from \"@storyshelf/core/utils\";\nimport type { ShelfRouter, ShelfLifecycle } from \"./app-types.ts\";\nimport { startBranchGcTimer } from \"./retention-timer.ts\";\nimport type { ServerRuntime } from \"./runtime.ts\";\n\n/** Mutable setup-settlement cell shared by the gate, health, and `setup()`. */\nexport interface LifecycleCell {\n ready: Promise<AdapterSetupResult>;\n settled: AdapterSetupResult | null;\n /** Fire-and-forget boot tasks (recovery, credential check) that teardown must outwait. */\n background?: Promise<unknown>[];\n /** Override of {@link BACKGROUND_DRAIN_MS} (tests). */\n drainMs?: number;\n}\n\n/** Longest teardown waits for background boot tasks before closing adapters anyway. */\nconst BACKGROUND_DRAIN_MS = 5000;\n\n/** Register a boot task so `teardown()` does not close adapters underneath it. */\nexport function trackBackground(cell: LifecycleCell, task: Promise<unknown>): void {\n (cell.background ??= []).push(task);\n}\n\n/** Wait for tracked boot tasks, bounded so a hung task can never block shutdown. */\nasync function drainBackground(cell: LifecycleCell): Promise<void> {\n const pending = Promise.allSettled(cell.background ?? []);\n let timer: NodeJS.Timeout | undefined;\n const timeout = new Promise<void>((resolve) => {\n timer = setTimeout(resolve, cell.drainMs ?? BACKGROUND_DRAIN_MS);\n timer.unref();\n });\n await Promise.race([pending, timeout]);\n clearTimeout(timer);\n}\n\nfunction trackSettlement(cell: LifecycleCell, promise: Promise<AdapterSetupResult>): void {\n promise.then(\n (result) => {\n cell.settled = result;\n },\n () => {\n cell.settled = { ok: false, failures: [] };\n },\n );\n}\n\n/** Kick the eager background setup run (resolves; never rejects). */\nfunction kickSetup(\n options: ShelfOptions,\n ctx: AdapterSetupContext,\n logger: Logger,\n cell: LifecycleCell,\n): void {\n // Auth is not an adapter: its one-shot boot validation runs first, then\n // the adapter setups. A failing secret check fails readiness, like others.\n cell.ready = (async (): Promise<AdapterSetupResult> => {\n const invalid = validateBootAssembly(options);\n if (invalid.length > 0) {\n logger.error({ failures: invalid }, \"adapter validation failed\");\n return { ok: false, failures: invalid };\n }\n const authFailed = await runAuthSetup(options);\n if (authFailed) {\n logger.error({ failures: authFailed.failures }, \"auth setup failed\");\n return authFailed;\n }\n return await runAdapterSetups(collectSetups(options), ctx, logger);\n })();\n trackSettlement(cell, cell.ready);\n}\n\n/** Login method count, or null when the engine throws during introspection. */\nfunction loginMethodCount(auth: NonNullable<ShelfOptions[\"auth\"]>): number | null {\n try {\n return auth.loginMethods().length;\n } catch {\n return null;\n }\n}\n\n/** Log which auth mode the server boots with (open servers stay visible). */\nfunction logAuthMode(options: ShelfOptions, logger: Logger): void {\n if (!options.auth) {\n logger.info(\"auth disabled (local default; every route is open)\");\n return;\n }\n const methods = loginMethodCount(options.auth);\n if (methods === null) {\n logger.warn(\"auth loginMethods() threw during boot logging\");\n return;\n }\n logger.info({ loginMethods: methods }, \"auth enabled\");\n}\n\n/** Kick eager setup and attach the `app.lifecycle` namespace (single run). */\nexport function attachLifecycle(\n app: ShelfRouter,\n options: ShelfOptions,\n runtime: ServerRuntime,\n cell: LifecycleCell,\n): void {\n const setupCtx: AdapterSetupContext = { config: runtime.config, logger: runtime.logger };\n bindAdapterLoggers(options, runtime.logger);\n logAuthMode(options, runtime.logger);\n kickSetup(options, setupCtx, runtime.logger, cell);\n const timer = startBranchGcInterval(options, runtime);\n Object.assign(app, {\n lifecycle: createLifecycle(options, setupCtx, runtime.logger, cell, timer),\n });\n}\n\nfunction startBranchGcInterval(\n options: ShelfOptions,\n runtime: ServerRuntime,\n): { stop(): void } | null {\n return startBranchGcTimer(options.database, options.storage, runtime.config, runtime.logger);\n}\n\n/** Run auth boot validation, returning a failure result instead of throwing. */\nasync function runAuthSetup(options: ShelfOptions): Promise<AdapterSetupResult | null> {\n try {\n await options.auth?.setup?.();\n return null;\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n return {\n ok: false,\n failures: [\n { category: \"auth\", kind: \"auth\", name: \"auth\", error: sanitizeErrorText(message, 500) },\n ],\n };\n }\n}\n/** Validation failure settled on the cell — null when the assembly is sound. */\nfunction invalidSources(options: ShelfOptions, cell: LifecycleCell): AdapterSetupResult | null {\n const invalid = validateBootAssembly(options);\n if (invalid.length === 0) {\n return null;\n }\n const result: AdapterSetupResult = { ok: false, failures: invalid };\n cell.ready = Promise.resolve(result);\n cell.settled = result;\n return result;\n}\n/** Build the `app.lifecycle` namespace closing over one settlement cell. */\nfunction createLifecycle(\n options: ShelfOptions,\n ctx: AdapterSetupContext,\n logger: Logger,\n cell: LifecycleCell,\n timer: { stop(): void } | null = null,\n): ShelfLifecycle {\n return {\n get ready() {\n return cell.ready;\n },\n logger,\n setup: async () => {\n const blocked = invalidSources(options, cell);\n if (blocked) {\n throw new AdapterLifecycleError(\"setup\", blocked.failures);\n }\n const result = await runAdapterSetups(collectSetups(options), ctx, logger);\n cell.ready = Promise.resolve(result);\n cell.settled = result;\n if (!result.ok) {\n throw new AdapterLifecycleError(\"setup\", result.failures);\n }\n },\n teardown: async () => {\n timer?.stop();\n await drainBackground(cell);\n const result = await runAdapterTeardowns(collectTeardowns(options), ctx, logger);\n await options.observability?.shutdown().catch((error: unknown) => {\n logger.error({ err: error }, \"observability shutdown failed\");\n });\n if (!result.ok) {\n throw new AdapterLifecycleError(\"teardown\", result.failures);\n }\n },\n };\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth-gate.mjs","names":[],"sources":["../../src/middleware/auth-gate.ts"],"sourcesContent":["import type { Auth } from \"@storyshelf/auth\";\nimport type { AuthUser } from \"@storyshelf/core/types\";\nimport type { Context, Next } from \"hono\";\nimport { getStore } from \"../store.ts\";\n\n/** Resolve the request user from the auth adapter (null when auth is off or a token is used). */\nexport async function resolveRequestUser(\n c: Pick<Context, \"req\">,\n auth?: Auth,\n): Promise<AuthUser | null> {\n if (!auth) {\n return null;\n }\n if (c.req.header(\"authorization\")) {\n return null;\n }\n return await auth.check(c.req.raw);\n}\n\n/**\n * Gate the server-rendered UI behind auth (ADR 0008): unauthenticated HTML\n * requests redirect to login. API/auth routes answer 401/403 themselves, and\n * published-Storybook routes enforce their own auth (public builds are\n * viewable without a session, ADR 0011).\n */\nexport function authGate() {\n // oxlint-disable-next-line typescript/no-invalid-void-type -- Hono middleware may not return Response\n return async (c: Context, next: Next): Promise<Response | void> => {\n const { user, authEnabled: isAuthEnabled } = getStore();\n const { path } = c.req;\n if (\n !isAuthEnabled ||\n user ||\n path.startsWith(\"/api/\") ||\n path.startsWith(\"/auth/\") ||\n path.startsWith(\"/assets/\") ||\n path.startsWith(\"/.well-known/\") ||\n (path.startsWith(\"/projects/\") && path.includes(\"/storybook\")) ||\n /^\\/projects\\/[^/]+\\/?$/u.test(path)\n ) {\n await next();\n return;\n }\n if (c.req.header(\"HX-Request\") === \"true\") {\n c.header(\"HX-Redirect\", \"/auth/login\");\n return c.body(null, 204);\n }\n return c.redirect(\"/auth/login\", 302);\n };\n}\n"],"mappings":";;;;;;AAMA,eAAsB,mBACpB,GACA,MAC0B;CAC1B,IAAI,CAAC,MACH,OAAO;CAET,IAAI,EAAE,IAAI,OAAO,eAAe,GAC9B,OAAO;CAET,OAAO,MAAM,KAAK,MAAM,EAAE,IAAI,GAAG;AACnC;;;;;;;AAQA,SAAgB,WAAW;CAEzB,OAAO,OAAO,GAAY,SAAyC;EACjE,MAAM,EAAE,MAAM,aAAa,kBAAkB,SAAS;EACtD,MAAM,EAAE,SAAS,EAAE;EACnB,IACE,CAAC,iBACD,QACA,KAAK,WAAW,OAAO,KACvB,KAAK,WAAW,QAAQ,KACxB,KAAK,WAAW,UAAU,KAC1B,KAAK,WAAW,eAAe,KAC9B,KAAK,WAAW,YAAY,KAAK,KAAK,SAAS,YAAY,KAC5D,0BAA0B,KAAK,IAAI,GACnC;GACA,MAAM,KAAK;GACX;EACF;EACA,IAAI,EAAE,IAAI,OAAO,YAAY,MAAM,QAAQ;GACzC,EAAE,OAAO,eAAe,aAAa;GACrC,OAAO,EAAE,KAAK,MAAM,GAAG;EACzB;EACA,OAAO,EAAE,SAAS,eAAe,GAAG;CACtC;AACF"}
|
|
1
|
+
{"version":3,"file":"auth-gate.mjs","names":[],"sources":["../../src/middleware/auth-gate.ts"],"sourcesContent":["import type { Auth } from \"@storyshelf/core/auth\";\nimport type { AuthUser } from \"@storyshelf/core/types\";\nimport type { Context, Next } from \"hono\";\nimport { getStore } from \"../store.ts\";\n\n/** Resolve the request user from the auth adapter (null when auth is off or a token is used). */\nexport async function resolveRequestUser(\n c: Pick<Context, \"req\">,\n auth?: Auth,\n): Promise<AuthUser | null> {\n if (!auth) {\n return null;\n }\n if (c.req.header(\"authorization\")) {\n return null;\n }\n return await auth.check(c.req.raw);\n}\n\n/**\n * Gate the server-rendered UI behind auth (ADR 0008): unauthenticated HTML\n * requests redirect to login. API/auth routes answer 401/403 themselves, and\n * published-Storybook routes enforce their own auth (public builds are\n * viewable without a session, ADR 0011).\n */\nexport function authGate() {\n // oxlint-disable-next-line typescript/no-invalid-void-type -- Hono middleware may not return Response\n return async (c: Context, next: Next): Promise<Response | void> => {\n const { user, authEnabled: isAuthEnabled } = getStore();\n const { path } = c.req;\n if (\n !isAuthEnabled ||\n user ||\n path.startsWith(\"/api/\") ||\n path.startsWith(\"/auth/\") ||\n path.startsWith(\"/assets/\") ||\n path.startsWith(\"/.well-known/\") ||\n (path.startsWith(\"/projects/\") && path.includes(\"/storybook\")) ||\n /^\\/projects\\/[^/]+\\/?$/u.test(path)\n ) {\n await next();\n return;\n }\n if (c.req.header(\"HX-Request\") === \"true\") {\n c.header(\"HX-Redirect\", \"/auth/login\");\n return c.body(null, 204);\n }\n return c.redirect(\"/auth/login\", 302);\n };\n}\n"],"mappings":";;;;;;AAMA,eAAsB,mBACpB,GACA,MAC0B;CAC1B,IAAI,CAAC,MACH,OAAO;CAET,IAAI,EAAE,IAAI,OAAO,eAAe,GAC9B,OAAO;CAET,OAAO,MAAM,KAAK,MAAM,EAAE,IAAI,GAAG;AACnC;;;;;;;AAQA,SAAgB,WAAW;CAEzB,OAAO,OAAO,GAAY,SAAyC;EACjE,MAAM,EAAE,MAAM,aAAa,kBAAkB,SAAS;EACtD,MAAM,EAAE,SAAS,EAAE;EACnB,IACE,CAAC,iBACD,QACA,KAAK,WAAW,OAAO,KACvB,KAAK,WAAW,QAAQ,KACxB,KAAK,WAAW,UAAU,KAC1B,KAAK,WAAW,eAAe,KAC9B,KAAK,WAAW,YAAY,KAAK,KAAK,SAAS,YAAY,KAC5D,0BAA0B,KAAK,IAAI,GACnC;GACA,MAAM,KAAK;GACX;EACF;EACA,IAAI,EAAE,IAAI,OAAO,YAAY,MAAM,QAAQ;GACzC,EAAE,OAAO,eAAe,aAAa;GACrC,OAAO,EAAE,KAAK,MAAM,GAAG;EACzB;EACA,OAAO,EAAE,SAAS,eAAe,GAAG;CACtC;AACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"admin-pages.mjs","names":[],"sources":["../../src/routers/admin-pages.ts"],"sourcesContent":["import type { Auth } from \"@storyshelf/auth\";\nimport type { AdapterSetupResult, AdapterSetupSources } from \"@storyshelf/core/adapter/setup\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { loadCredentialProbe, reencryptWithCurrent } from \"../credentials.ts\";\nimport { renderAdminSystemPage } from \"../pages/admin-system.tsx\";\nimport { getStore } from \"../store.ts\";\nimport { flash } from \"./flash.ts\";\nimport { collectHealthReport } from \"./health-report.ts\";\nimport { requireSiteAdmin } from \"./helpers.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\n\n/** Dependencies for the admin System page (same probing inputs as deep health). */\nexport interface AdminPageDeps {\n sources: AdapterSetupSources;\n getSettled: () => AdapterSetupResult | null;\n bootTimeMs: number;\n version: string;\n}\n\n/** Register the site-admin System page (adapter inventory + in-depth health). */\nexport function registerAdminPages(app: ShelfRouter, deps: AdminPageDeps, auth?: Auth): void {\n app.get(\"/admin\", async (c) => {\n requireSiteAdmin(c);\n const { authEnabled, config, db } = getStore();\n const credentials = await loadCredentialProbe(db, config);\n const report = await collectHealthReport(\n deps.sources,\n deps.getSettled(),\n deps.bootTimeMs,\n deps.version,\n credentials,\n );\n const authMethods = auth?.loginMethods().map((method) => method.label);\n c.header(\"Cache-Control\", \"no-store\");\n return c.html(renderAdminSystemPage({ report, authEnabled, config, authMethods, credentials }));\n });\n\n app.post(\"/admin/credentials/reencrypt\", async (c) => {\n requireSiteAdmin(c);\n const { db, config } = getStore();\n if (!config.previousSecret) {\n flash(c, \"No previous secret is configured\", \"warning\");\n return hxRedirect(c, \"/admin\");\n }\n const result = await reencryptWithCurrent(db, config);\n flash(c, reencryptMessage(result), result.failed > 0 ? \"warning\" : \"success\");\n return hxRedirect(c, \"/admin\");\n });\n}\n\nfunction reencryptMessage(result: { reencrypted: number; failed: number }): string {\n const base = `Re-encrypted ${result.reencrypted} credential(s)`;\n return result.failed > 0 ? `${base}; ${result.failed} failed — see server logs` : base;\n}\n"],"mappings":";;;;;;;;;;;;AAoBA,SAAgB,mBAAmB,KAAkB,MAAqB,MAAmB;CAC3F,IAAI,IAAI,UAAU,OAAO,MAAM;EAC7B,iBAAiB,CAAC;EAClB,MAAM,EAAE,aAAa,QAAQ,OAAO,SAAS;EAC7C,MAAM,cAAc,MAAM,oBAAoB,IAAI,MAAM;EACxD,MAAM,SAAS,MAAM,oBACnB,KAAK,SACL,KAAK,WAAW,GAChB,KAAK,YACL,KAAK,SACL,WACF;EACA,MAAM,cAAc,MAAM,aAAa,CAAC,CAAC,KAAK,WAAW,OAAO,KAAK;EACrE,EAAE,OAAO,iBAAiB,UAAU;EACpC,OAAO,EAAE,KAAK,sBAAsB;GAAE;GAAQ;GAAa;GAAQ;GAAa;EAAY,CAAC,CAAC;CAChG,CAAC;CAED,IAAI,KAAK,gCAAgC,OAAO,MAAM;EACpD,iBAAiB,CAAC;EAClB,MAAM,EAAE,IAAI,WAAW,SAAS;EAChC,IAAI,CAAC,OAAO,gBAAgB;GAC1B,MAAM,GAAG,oCAAoC,SAAS;GACtD,OAAO,WAAW,GAAG,QAAQ;EAC/B;EACA,MAAM,SAAS,MAAM,qBAAqB,IAAI,MAAM;EACpD,MAAM,GAAG,iBAAiB,MAAM,GAAG,OAAO,SAAS,IAAI,YAAY,SAAS;EAC5E,OAAO,WAAW,GAAG,QAAQ;CAC/B,CAAC;AACH;AAEA,SAAS,iBAAiB,QAAyD;CACjF,MAAM,OAAO,gBAAgB,OAAO,YAAY;CAChD,OAAO,OAAO,SAAS,IAAI,GAAG,KAAK,IAAI,OAAO,OAAO,6BAA6B;AACpF"}
|
|
1
|
+
{"version":3,"file":"admin-pages.mjs","names":[],"sources":["../../src/routers/admin-pages.ts"],"sourcesContent":["import type { Auth } from \"@storyshelf/core/auth\";\nimport type { AdapterSetupResult, AdapterSetupSources } from \"@storyshelf/core/adapter/setup\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { loadCredentialProbe, reencryptWithCurrent } from \"../credentials.ts\";\nimport { renderAdminSystemPage } from \"../pages/admin-system.tsx\";\nimport { getStore } from \"../store.ts\";\nimport { flash } from \"./flash.ts\";\nimport { collectHealthReport } from \"./health-report.ts\";\nimport { requireSiteAdmin } from \"./helpers.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\n\n/** Dependencies for the admin System page (same probing inputs as deep health). */\nexport interface AdminPageDeps {\n sources: AdapterSetupSources;\n getSettled: () => AdapterSetupResult | null;\n bootTimeMs: number;\n version: string;\n}\n\n/** Register the site-admin System page (adapter inventory + in-depth health). */\nexport function registerAdminPages(app: ShelfRouter, deps: AdminPageDeps, auth?: Auth): void {\n app.get(\"/admin\", async (c) => {\n requireSiteAdmin(c);\n const { authEnabled, config, db } = getStore();\n const credentials = await loadCredentialProbe(db, config);\n const report = await collectHealthReport(\n deps.sources,\n deps.getSettled(),\n deps.bootTimeMs,\n deps.version,\n credentials,\n );\n const authMethods = auth?.loginMethods().map((method) => method.label);\n c.header(\"Cache-Control\", \"no-store\");\n return c.html(renderAdminSystemPage({ report, authEnabled, config, authMethods, credentials }));\n });\n\n app.post(\"/admin/credentials/reencrypt\", async (c) => {\n requireSiteAdmin(c);\n const { db, config } = getStore();\n if (!config.previousSecret) {\n flash(c, \"No previous secret is configured\", \"warning\");\n return hxRedirect(c, \"/admin\");\n }\n const result = await reencryptWithCurrent(db, config);\n flash(c, reencryptMessage(result), result.failed > 0 ? \"warning\" : \"success\");\n return hxRedirect(c, \"/admin\");\n });\n}\n\nfunction reencryptMessage(result: { reencrypted: number; failed: number }): string {\n const base = `Re-encrypted ${result.reencrypted} credential(s)`;\n return result.failed > 0 ? `${base}; ${result.failed} failed — see server logs` : base;\n}\n"],"mappings":";;;;;;;;;;;;AAoBA,SAAgB,mBAAmB,KAAkB,MAAqB,MAAmB;CAC3F,IAAI,IAAI,UAAU,OAAO,MAAM;EAC7B,iBAAiB,CAAC;EAClB,MAAM,EAAE,aAAa,QAAQ,OAAO,SAAS;EAC7C,MAAM,cAAc,MAAM,oBAAoB,IAAI,MAAM;EACxD,MAAM,SAAS,MAAM,oBACnB,KAAK,SACL,KAAK,WAAW,GAChB,KAAK,YACL,KAAK,SACL,WACF;EACA,MAAM,cAAc,MAAM,aAAa,CAAC,CAAC,KAAK,WAAW,OAAO,KAAK;EACrE,EAAE,OAAO,iBAAiB,UAAU;EACpC,OAAO,EAAE,KAAK,sBAAsB;GAAE;GAAQ;GAAa;GAAQ;GAAa;EAAY,CAAC,CAAC;CAChG,CAAC;CAED,IAAI,KAAK,gCAAgC,OAAO,MAAM;EACpD,iBAAiB,CAAC;EAClB,MAAM,EAAE,IAAI,WAAW,SAAS;EAChC,IAAI,CAAC,OAAO,gBAAgB;GAC1B,MAAM,GAAG,oCAAoC,SAAS;GACtD,OAAO,WAAW,GAAG,QAAQ;EAC/B;EACA,MAAM,SAAS,MAAM,qBAAqB,IAAI,MAAM;EACpD,MAAM,GAAG,iBAAiB,MAAM,GAAG,OAAO,SAAS,IAAI,YAAY,SAAS;EAC5E,OAAO,WAAW,GAAG,QAAQ;CAC/B,CAAC;AACH;AAEA,SAAS,iBAAiB,QAAyD;CACjF,MAAM,OAAO,gBAAgB,OAAO,YAAY;CAChD,OAAO,OAAO,SAAS,IAAI,GAAG,KAAK,IAAI,OAAO,OAAO,6BAA6B;AACpF"}
|
package/dist/routers/auth.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { syncLoginMemberships } from "./auth-sync.mjs";
|
|
|
10
10
|
import { UserModel } from "@storyshelf/core/models";
|
|
11
11
|
import { SESSION_COOKIE } from "@storyshelf/core/types";
|
|
12
12
|
import { HTTPException } from "hono/http-exception";
|
|
13
|
-
import { MIN_PASSWORD_LENGTH } from "@storyshelf/auth";
|
|
13
|
+
import { MIN_PASSWORD_LENGTH } from "@storyshelf/core/auth";
|
|
14
14
|
//#region src/routers/auth.ts
|
|
15
15
|
/** Provider ids are URL path segments: strict charset, bounded length. */
|
|
16
16
|
function validProviderId(value) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.mjs","names":[],"sources":["../../src/routers/auth.ts"],"sourcesContent":["import type { Auth, EngineLoginMethod } from \"@storyshelf/auth\";\nimport { MIN_PASSWORD_LENGTH } from \"@storyshelf/auth\";\nimport { UserModel } from \"@storyshelf/core/models\";\nimport { SESSION_COOKIE } from \"@storyshelf/core/types\";\nimport type { Context } from \"hono\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { notifySystem } from \"../notify.ts\";\nimport { renderInviteInvalidPage, renderInvitePage } from \"../pages/invite.tsx\";\nimport { renderLoginPage } from \"../pages/login.tsx\";\nimport { getStore } from \"../store.ts\";\nimport { syncLoginMemberships } from \"./auth-sync.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\n\n/** Provider ids are URL path segments: strict charset, bounded length. */\nfunction validProviderId(value: string): boolean {\n return /^[a-zA-Z0-9_-]{1,64}$/u.test(value);\n}\n\nconst ENGINE_PASSWORD_ACTION = \"/auth/engine/login\";\n\n/** Forward a request to the mounted engine, preserving origin and cookies. */\nexport async function forwardToEngine(\n c: Context,\n engine: Auth,\n path: string,\n body?: unknown,\n): Promise<Response> {\n return await forwardEngineRequest(c, engine, path, \"POST\", body);\n}\n\nasync function forwardEngineRequest(\n c: Context,\n engine: Auth,\n path: string,\n method: \"GET\" | \"POST\",\n body?: unknown,\n): Promise<Response> {\n const headers = new Headers();\n for (const name of [\"cookie\", \"origin\", \"referer\", \"user-agent\"]) {\n const value = c.req.header(name);\n if (value) {\n headers.set(name, value);\n }\n }\n if (method === \"POST\") {\n headers.set(\"content-type\", \"application/json\");\n }\n return await engine.handler(\n new Request(new URL(path, c.req.url), {\n method,\n headers,\n ...(method === \"POST\" ? { body: JSON.stringify(body ?? {}) } : {}),\n }),\n );\n}\n\nfunction copySetCookies(c: Context, res: Response): void {\n const cookies =\n typeof res.headers.getSetCookie === \"function\"\n ? res.headers.getSetCookie()\n : splitSetCookie(res.headers.get(\"set-cookie\"));\n for (const cookie of cookies) {\n c.header(\"set-cookie\", cookie, { append: true });\n }\n}\n\n/** Split a joined Set-Cookie header (runtimes without getSetCookie). */\nfunction splitSetCookie(header: string | null): string[] {\n if (!header) {\n return [];\n }\n // Split on commas starting a new `name=` pair; Expires dates (\"Wed, 21\")\n // never match (no `=` after the token). Fallback only — Node has getSetCookie.\n return header\n .split(/,\\s*(?=[^;\\s=]+=)/u)\n .map((part) => part.trim())\n .filter((part) => part !== \"\");\n}\n\nfunction loginLinks(\n methods: EngineLoginMethod[],\n): { kind: \"password\" | \"oauth\" | \"passkey\" | \"sso\"; id: string; label: string; url: string }[] {\n return methods.map((method) => ({\n ...method,\n // Passkey sign-in is JS-driven on the page; the url is an unused fallback.\n url:\n method.kind === \"oauth\" || method.kind === \"sso\"\n ? `/auth/engine/${method.id}`\n : ENGINE_PASSWORD_ACTION,\n }));\n}\n\nasync function renderEngineLogin(\n c: Context,\n engine: Auth,\n error?: string,\n email?: string,\n status?: 200 | 400 | 401 | 429 | 500 | 502,\n): Promise<Response> {\n return c.html(\n await renderLoginPage({\n error,\n email,\n engineMethods: loginLinks(engine.loginMethods()),\n }),\n status ?? 200,\n );\n}\n\n/** Membership sync for first-seen engine users only (never demote existing roles). */\nasync function syncEngineUser(user: { id: string; email: string; name: string }): Promise<void> {\n const existing = await new UserModel(getStore().db).get(user.id);\n if (existing) {\n return;\n }\n await syncLoginMemberships(\n getStore().db,\n {\n users: getStore().db.tables.users,\n projects: getStore().db.tables.projects,\n projectMembers: getStore().db.tables.projectMembers,\n projectGroupMappings: getStore().db.tables.projectGroupMappings,\n },\n { ...user, role: \"member\" },\n );\n}\n\nasync function readCredentials(c: Context): Promise<{ email: string; password: string }> {\n const form = await c.req.formData();\n const rawEmail = form.get(\"email\");\n const rawPassword = form.get(\"password\");\n return {\n email: typeof rawEmail === \"string\" ? rawEmail : \"\",\n password: typeof rawPassword === \"string\" ? rawPassword : \"\",\n };\n}\n\nasync function finishCredentialLogin(\n c: Context,\n engine: Auth,\n email: string,\n password: string,\n): Promise<Response> {\n const res = await forwardToEngine(c, engine, \"/api/auth/sign-in/email\", { email, password });\n if (res.status !== 200) {\n // Generic message either way (no user enumeration), but propagate\n // rate-limit/upstream statuses instead of masking them as 401.\n if (res.status === 429) {\n return renderEngineLogin(c, engine, \"Invalid credentials\", email, 429);\n }\n if (res.status >= 500) {\n return renderEngineLogin(c, engine, \"Invalid credentials\", email, 502);\n }\n if (res.status === 400) {\n return renderEngineLogin(c, engine, \"Enter a valid email address\", email, 400);\n }\n // Credential failures only (never rate-limit/upstream noise, never\n // secrets): admins opted into sys:* see a login-attempt signal. Spray\n // attempts can fan out here; the /auth/* rate-limit bucket bounds it.\n await notifySystem(\"sys:auth-failed\", { email });\n return renderEngineLogin(c, engine, \"Invalid credentials\", email, 401);\n }\n copySetCookies(c, res);\n const body = (await res.json()) as { user: { id: string; email: string; name: string } };\n await syncEngineUser(body.user);\n return hxRedirect(c, \"/\");\n}\n\nasync function handleEngineLogin(c: Context, engine: Auth): Promise<Response> {\n const { email, password } = await readCredentials(c);\n return finishCredentialLogin(c, engine, email, password);\n}\n\nfunction resolveEngineMethod(c: Context, engine: Auth): EngineLoginMethod | null {\n const id = c.req.param(\"providerId\") ?? \"\";\n if (!validProviderId(id)) {\n throw new HTTPException(400, { message: \"Unknown provider\" });\n }\n return (\n engine.loginMethods().find((entry) => entry.id === id && entry.kind !== \"password\") ?? null\n );\n}\n\nasync function finishOAuthStart(c: Context, engine: Auth, id: string): Promise<Response> {\n const res = await forwardToEngine(c, engine, \"/api/auth/sign-in/social\", {\n provider: id,\n callbackURL: \"/\",\n disableRedirect: true,\n });\n if (res.status !== 200) {\n throw new HTTPException(502, { message: \"Sign in failed\" });\n }\n const body = (await res.json()) as { url?: string };\n if (!body.url) {\n throw new HTTPException(502, { message: \"Sign in failed\" });\n }\n copySetCookies(c, res);\n return c.redirect(body.url);\n}\n\nasync function finishSSOStart(c: Context, engine: Auth, id: string): Promise<Response> {\n const res = await forwardToEngine(c, engine, \"/api/auth/sign-in/sso\", {\n providerId: id,\n callbackURL: \"/\",\n });\n if (res.status !== 200) {\n throw new HTTPException(502, { message: \"Sign in failed\" });\n }\n const body = (await res.json()) as { url?: string };\n if (!body.url) {\n throw new HTTPException(502, { message: \"Sign in failed\" });\n }\n copySetCookies(c, res);\n return c.redirect(body.url);\n}\n\nasync function handleEngineStart(c: Context, engine: Auth): Promise<Response> {\n const method = resolveEngineMethod(c, engine);\n if (!method || method.kind === \"passkey\") {\n return c.notFound();\n }\n if (method.kind === \"sso\") {\n return await finishSSOStart(c, engine, method.id);\n }\n return await finishOAuthStart(c, engine, method.id);\n}\n\nasync function handleInvitePage(c: Context, engine: Auth): Promise<Response> {\n const inviteId = c.req.param(\"inviteId\") ?? \"\";\n const token = c.req.query(\"token\") ?? \"\";\n // The invite token renders into the page: never store.\n c.header(\"Cache-Control\", \"no-store\");\n if (!inviteId || !token) {\n return c.html(renderInviteInvalidPage(\"Missing invite link parameters\"), 400);\n }\n try {\n const { email, name } = await engine.verifyInvite({ inviteId, token });\n return c.html(await renderInvitePage({ inviteId, token, email, name }));\n } catch {\n return c.html(renderInviteInvalidPage(\"Invalid or expired invite\"), 400);\n }\n}\n\nasync function readInviteForm(c: Context): Promise<{\n inviteId: string;\n token: string;\n password: string;\n confirm: string;\n}> {\n const form = await c.req.formData();\n return {\n inviteId: c.req.param(\"inviteId\") ?? \"\",\n token: formText(form.get(\"token\")),\n password: formText(form.get(\"password\")),\n confirm: formText(form.get(\"confirm\")),\n };\n}\n\nfunction formText(value: unknown): string {\n return typeof value === \"string\" ? value : \"\";\n}\n\nasync function finishInviteAccept(\n c: Context,\n engine: Auth,\n inviteId: string,\n token: string,\n password: string,\n): Promise<Response> {\n const user = await engine.acceptInvite({ inviteId, token, password });\n const res = await forwardToEngine(c, engine, \"/api/auth/sign-in/email\", {\n email: user.email,\n password,\n });\n if (res.status !== 200) {\n throw new Error(\"Sign in failed\");\n }\n copySetCookies(c, res);\n await syncEngineUser({ id: user.id, email: user.email, name: user.name });\n return c.redirect(\"/profile\");\n}\n\nasync function inviteAcceptError(\n c: Context,\n inviteId: string,\n token: string,\n error: unknown,\n): Promise<Response> {\n const message = error instanceof Error ? error.message : \"Invalid or expired invite\";\n if (message.includes(`at least ${MIN_PASSWORD_LENGTH}`)) {\n return c.html(await renderInvitePage({ inviteId, token, error: message }), 400);\n }\n return c.html(renderInviteInvalidPage(message), 400);\n}\n\nasync function handleInviteAccept(c: Context, engine: Auth): Promise<Response> {\n const { inviteId, token, password, confirm } = await readInviteForm(c);\n if (!inviteId || !token) {\n return c.html(renderInviteInvalidPage(\"Missing invite link parameters\"), 400);\n }\n if (password !== confirm) {\n return c.html(\n await renderInvitePage({ inviteId, token, error: \"Passwords do not match\" }),\n 400,\n );\n }\n try {\n return await finishInviteAccept(c, engine, inviteId, token, password);\n } catch (error) {\n return await inviteAcceptError(c, inviteId, token, error);\n }\n}\n\n/** Register the engine mount and descriptor-driven login routes. */\nexport function registerEngineAuth(app: ShelfRouter, engine: Auth): void {\n app.all(\"/api/auth/*\", async (c) => await engine.handler(c.req.raw));\n\n app.get(\"/auth/login\", async (c) => {\n const methods = engine.loginMethods();\n const sole = methods.length === 1 ? methods.at(0) : undefined;\n if (sole?.kind === \"oauth\" || sole?.kind === \"sso\") {\n return c.redirect(`/auth/engine/${sole.id}`);\n }\n return await renderEngineLogin(c, engine);\n });\n\n // A failed POST leaves the browser on this URL; a reload must not 404.\n app.get(\"/auth/engine/login\", (c) => c.redirect(\"/auth/login\"));\n\n app.post(\"/auth/engine/login\", async (c) => await handleEngineLogin(c, engine));\n\n app.get(\"/auth/engine/:providerId\", async (c) => await handleEngineStart(c, engine));\n\n app.get(\"/auth/invites/:inviteId\", async (c) => await handleInvitePage(c, engine));\n\n app.post(\"/auth/invites/:inviteId\", async (c) => await handleInviteAccept(c, engine));\n\n app.post(\"/auth/logout\", async (c) => {\n const res = await forwardToEngine(c, engine, \"/api/auth/sign-out\", {});\n copySetCookies(c, res);\n // Mirror the Secure attribute or the clear-cookie misses Secure session\n // cookies over HTTPS (behind TLS-terminating proxies check forwarded proto).\n const secure =\n new URL(c.req.url).protocol === \"https:\" || c.req.header(\"x-forwarded-proto\") === \"https\";\n c.header(\n \"set-cookie\",\n `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure ? \"; Secure\" : \"\"}`,\n { append: true },\n );\n return hxRedirect(c, \"/auth/login\");\n });\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"auth.mjs","names":[],"sources":["../../src/routers/auth.ts"],"sourcesContent":["import type { Auth, EngineLoginMethod } from \"@storyshelf/core/auth\";\nimport { MIN_PASSWORD_LENGTH } from \"@storyshelf/core/auth\";\nimport { UserModel } from \"@storyshelf/core/models\";\nimport { SESSION_COOKIE } from \"@storyshelf/core/types\";\nimport type { Context } from \"hono\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { notifySystem } from \"../notify.ts\";\nimport { renderInviteInvalidPage, renderInvitePage } from \"../pages/invite.tsx\";\nimport { renderLoginPage } from \"../pages/login.tsx\";\nimport { getStore } from \"../store.ts\";\nimport { syncLoginMemberships } from \"./auth-sync.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\n\n/** Provider ids are URL path segments: strict charset, bounded length. */\nfunction validProviderId(value: string): boolean {\n return /^[a-zA-Z0-9_-]{1,64}$/u.test(value);\n}\n\nconst ENGINE_PASSWORD_ACTION = \"/auth/engine/login\";\n\n/** Forward a request to the mounted engine, preserving origin and cookies. */\nexport async function forwardToEngine(\n c: Context,\n engine: Auth,\n path: string,\n body?: unknown,\n): Promise<Response> {\n return await forwardEngineRequest(c, engine, path, \"POST\", body);\n}\n\nasync function forwardEngineRequest(\n c: Context,\n engine: Auth,\n path: string,\n method: \"GET\" | \"POST\",\n body?: unknown,\n): Promise<Response> {\n const headers = new Headers();\n for (const name of [\"cookie\", \"origin\", \"referer\", \"user-agent\"]) {\n const value = c.req.header(name);\n if (value) {\n headers.set(name, value);\n }\n }\n if (method === \"POST\") {\n headers.set(\"content-type\", \"application/json\");\n }\n return await engine.handler(\n new Request(new URL(path, c.req.url), {\n method,\n headers,\n ...(method === \"POST\" ? { body: JSON.stringify(body ?? {}) } : {}),\n }),\n );\n}\n\nfunction copySetCookies(c: Context, res: Response): void {\n const cookies =\n typeof res.headers.getSetCookie === \"function\"\n ? res.headers.getSetCookie()\n : splitSetCookie(res.headers.get(\"set-cookie\"));\n for (const cookie of cookies) {\n c.header(\"set-cookie\", cookie, { append: true });\n }\n}\n\n/** Split a joined Set-Cookie header (runtimes without getSetCookie). */\nfunction splitSetCookie(header: string | null): string[] {\n if (!header) {\n return [];\n }\n // Split on commas starting a new `name=` pair; Expires dates (\"Wed, 21\")\n // never match (no `=` after the token). Fallback only — Node has getSetCookie.\n return header\n .split(/,\\s*(?=[^;\\s=]+=)/u)\n .map((part) => part.trim())\n .filter((part) => part !== \"\");\n}\n\nfunction loginLinks(\n methods: EngineLoginMethod[],\n): { kind: \"password\" | \"oauth\" | \"passkey\" | \"sso\"; id: string; label: string; url: string }[] {\n return methods.map((method) => ({\n ...method,\n // Passkey sign-in is JS-driven on the page; the url is an unused fallback.\n url:\n method.kind === \"oauth\" || method.kind === \"sso\"\n ? `/auth/engine/${method.id}`\n : ENGINE_PASSWORD_ACTION,\n }));\n}\n\nasync function renderEngineLogin(\n c: Context,\n engine: Auth,\n error?: string,\n email?: string,\n status?: 200 | 400 | 401 | 429 | 500 | 502,\n): Promise<Response> {\n return c.html(\n await renderLoginPage({\n error,\n email,\n engineMethods: loginLinks(engine.loginMethods()),\n }),\n status ?? 200,\n );\n}\n\n/** Membership sync for first-seen engine users only (never demote existing roles). */\nasync function syncEngineUser(user: { id: string; email: string; name: string }): Promise<void> {\n const existing = await new UserModel(getStore().db).get(user.id);\n if (existing) {\n return;\n }\n await syncLoginMemberships(\n getStore().db,\n {\n users: getStore().db.tables.users,\n projects: getStore().db.tables.projects,\n projectMembers: getStore().db.tables.projectMembers,\n projectGroupMappings: getStore().db.tables.projectGroupMappings,\n },\n { ...user, role: \"member\" },\n );\n}\n\nasync function readCredentials(c: Context): Promise<{ email: string; password: string }> {\n const form = await c.req.formData();\n const rawEmail = form.get(\"email\");\n const rawPassword = form.get(\"password\");\n return {\n email: typeof rawEmail === \"string\" ? rawEmail : \"\",\n password: typeof rawPassword === \"string\" ? rawPassword : \"\",\n };\n}\n\nasync function finishCredentialLogin(\n c: Context,\n engine: Auth,\n email: string,\n password: string,\n): Promise<Response> {\n const res = await forwardToEngine(c, engine, \"/api/auth/sign-in/email\", { email, password });\n if (res.status !== 200) {\n // Generic message either way (no user enumeration), but propagate\n // rate-limit/upstream statuses instead of masking them as 401.\n if (res.status === 429) {\n return renderEngineLogin(c, engine, \"Invalid credentials\", email, 429);\n }\n if (res.status >= 500) {\n return renderEngineLogin(c, engine, \"Invalid credentials\", email, 502);\n }\n if (res.status === 400) {\n return renderEngineLogin(c, engine, \"Enter a valid email address\", email, 400);\n }\n // Credential failures only (never rate-limit/upstream noise, never\n // secrets): admins opted into sys:* see a login-attempt signal. Spray\n // attempts can fan out here; the /auth/* rate-limit bucket bounds it.\n await notifySystem(\"sys:auth-failed\", { email });\n return renderEngineLogin(c, engine, \"Invalid credentials\", email, 401);\n }\n copySetCookies(c, res);\n const body = (await res.json()) as { user: { id: string; email: string; name: string } };\n await syncEngineUser(body.user);\n return hxRedirect(c, \"/\");\n}\n\nasync function handleEngineLogin(c: Context, engine: Auth): Promise<Response> {\n const { email, password } = await readCredentials(c);\n return finishCredentialLogin(c, engine, email, password);\n}\n\nfunction resolveEngineMethod(c: Context, engine: Auth): EngineLoginMethod | null {\n const id = c.req.param(\"providerId\") ?? \"\";\n if (!validProviderId(id)) {\n throw new HTTPException(400, { message: \"Unknown provider\" });\n }\n return (\n engine.loginMethods().find((entry) => entry.id === id && entry.kind !== \"password\") ?? null\n );\n}\n\nasync function finishOAuthStart(c: Context, engine: Auth, id: string): Promise<Response> {\n const res = await forwardToEngine(c, engine, \"/api/auth/sign-in/social\", {\n provider: id,\n callbackURL: \"/\",\n disableRedirect: true,\n });\n if (res.status !== 200) {\n throw new HTTPException(502, { message: \"Sign in failed\" });\n }\n const body = (await res.json()) as { url?: string };\n if (!body.url) {\n throw new HTTPException(502, { message: \"Sign in failed\" });\n }\n copySetCookies(c, res);\n return c.redirect(body.url);\n}\n\nasync function finishSSOStart(c: Context, engine: Auth, id: string): Promise<Response> {\n const res = await forwardToEngine(c, engine, \"/api/auth/sign-in/sso\", {\n providerId: id,\n callbackURL: \"/\",\n });\n if (res.status !== 200) {\n throw new HTTPException(502, { message: \"Sign in failed\" });\n }\n const body = (await res.json()) as { url?: string };\n if (!body.url) {\n throw new HTTPException(502, { message: \"Sign in failed\" });\n }\n copySetCookies(c, res);\n return c.redirect(body.url);\n}\n\nasync function handleEngineStart(c: Context, engine: Auth): Promise<Response> {\n const method = resolveEngineMethod(c, engine);\n if (!method || method.kind === \"passkey\") {\n return c.notFound();\n }\n if (method.kind === \"sso\") {\n return await finishSSOStart(c, engine, method.id);\n }\n return await finishOAuthStart(c, engine, method.id);\n}\n\nasync function handleInvitePage(c: Context, engine: Auth): Promise<Response> {\n const inviteId = c.req.param(\"inviteId\") ?? \"\";\n const token = c.req.query(\"token\") ?? \"\";\n // The invite token renders into the page: never store.\n c.header(\"Cache-Control\", \"no-store\");\n if (!inviteId || !token) {\n return c.html(renderInviteInvalidPage(\"Missing invite link parameters\"), 400);\n }\n try {\n const { email, name } = await engine.verifyInvite({ inviteId, token });\n return c.html(await renderInvitePage({ inviteId, token, email, name }));\n } catch {\n return c.html(renderInviteInvalidPage(\"Invalid or expired invite\"), 400);\n }\n}\n\nasync function readInviteForm(c: Context): Promise<{\n inviteId: string;\n token: string;\n password: string;\n confirm: string;\n}> {\n const form = await c.req.formData();\n return {\n inviteId: c.req.param(\"inviteId\") ?? \"\",\n token: formText(form.get(\"token\")),\n password: formText(form.get(\"password\")),\n confirm: formText(form.get(\"confirm\")),\n };\n}\n\nfunction formText(value: unknown): string {\n return typeof value === \"string\" ? value : \"\";\n}\n\nasync function finishInviteAccept(\n c: Context,\n engine: Auth,\n inviteId: string,\n token: string,\n password: string,\n): Promise<Response> {\n const user = await engine.acceptInvite({ inviteId, token, password });\n const res = await forwardToEngine(c, engine, \"/api/auth/sign-in/email\", {\n email: user.email,\n password,\n });\n if (res.status !== 200) {\n throw new Error(\"Sign in failed\");\n }\n copySetCookies(c, res);\n await syncEngineUser({ id: user.id, email: user.email, name: user.name });\n return c.redirect(\"/profile\");\n}\n\nasync function inviteAcceptError(\n c: Context,\n inviteId: string,\n token: string,\n error: unknown,\n): Promise<Response> {\n const message = error instanceof Error ? error.message : \"Invalid or expired invite\";\n if (message.includes(`at least ${MIN_PASSWORD_LENGTH}`)) {\n return c.html(await renderInvitePage({ inviteId, token, error: message }), 400);\n }\n return c.html(renderInviteInvalidPage(message), 400);\n}\n\nasync function handleInviteAccept(c: Context, engine: Auth): Promise<Response> {\n const { inviteId, token, password, confirm } = await readInviteForm(c);\n if (!inviteId || !token) {\n return c.html(renderInviteInvalidPage(\"Missing invite link parameters\"), 400);\n }\n if (password !== confirm) {\n return c.html(\n await renderInvitePage({ inviteId, token, error: \"Passwords do not match\" }),\n 400,\n );\n }\n try {\n return await finishInviteAccept(c, engine, inviteId, token, password);\n } catch (error) {\n return await inviteAcceptError(c, inviteId, token, error);\n }\n}\n\n/** Register the engine mount and descriptor-driven login routes. */\nexport function registerEngineAuth(app: ShelfRouter, engine: Auth): void {\n app.all(\"/api/auth/*\", async (c) => await engine.handler(c.req.raw));\n\n app.get(\"/auth/login\", async (c) => {\n const methods = engine.loginMethods();\n const sole = methods.length === 1 ? methods.at(0) : undefined;\n if (sole?.kind === \"oauth\" || sole?.kind === \"sso\") {\n return c.redirect(`/auth/engine/${sole.id}`);\n }\n return await renderEngineLogin(c, engine);\n });\n\n // A failed POST leaves the browser on this URL; a reload must not 404.\n app.get(\"/auth/engine/login\", (c) => c.redirect(\"/auth/login\"));\n\n app.post(\"/auth/engine/login\", async (c) => await handleEngineLogin(c, engine));\n\n app.get(\"/auth/engine/:providerId\", async (c) => await handleEngineStart(c, engine));\n\n app.get(\"/auth/invites/:inviteId\", async (c) => await handleInvitePage(c, engine));\n\n app.post(\"/auth/invites/:inviteId\", async (c) => await handleInviteAccept(c, engine));\n\n app.post(\"/auth/logout\", async (c) => {\n const res = await forwardToEngine(c, engine, \"/api/auth/sign-out\", {});\n copySetCookies(c, res);\n // Mirror the Secure attribute or the clear-cookie misses Secure session\n // cookies over HTTPS (behind TLS-terminating proxies check forwarded proto).\n const secure =\n new URL(c.req.url).protocol === \"https:\" || c.req.header(\"x-forwarded-proto\") === \"https\";\n c.header(\n \"set-cookie\",\n `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure ? \"; Secure\" : \"\"}`,\n { append: true },\n );\n return hxRedirect(c, \"/auth/login\");\n });\n}\n"],"mappings":"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"}
|
package/dist/routers/profile.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { forwardToEngine } from "./auth.mjs";
|
|
|
8
8
|
import { renderProfilePage } from "../pages/profile.mjs";
|
|
9
9
|
import { NotificationSubscriptionModel, ProjectModel, UserModel } from "@storyshelf/core/models";
|
|
10
10
|
import { SESSION_COOKIE } from "@storyshelf/core/types";
|
|
11
|
-
import { MIN_PASSWORD_LENGTH, unsignedToken } from "@storyshelf/auth";
|
|
11
|
+
import { MIN_PASSWORD_LENGTH, unsignedToken } from "@storyshelf/core/auth";
|
|
12
12
|
import { eq, getTableColumns } from "@storyshelf/core/orm";
|
|
13
13
|
//#region src/routers/profile.ts
|
|
14
14
|
function field(form, name) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"profile.mjs","names":[],"sources":["../../src/routers/profile.ts"],"sourcesContent":["import type { Auth } from \"@storyshelf/auth\";\nimport { MIN_PASSWORD_LENGTH, unsignedToken } from \"@storyshelf/auth\";\nimport { NotificationSubscriptionModel, ProjectModel, UserModel } from \"@storyshelf/core/models\";\nimport { eq, getTableColumns } from \"@storyshelf/core/orm\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport { SESSION_COOKIE, type AuthUser } from \"@storyshelf/core/types\";\nimport type { Context } from \"hono\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { renderProfilePage } from \"../pages/profile.tsx\";\nimport { getStore } from \"../store.ts\";\nimport { forwardToEngine } from \"./auth.ts\";\nimport { flash } from \"./flash.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\n\n/** User columns needed to render the profile page. */\ninterface ProfileRow {\n email: string;\n name: string;\n displayNameOverride: string | null;\n authProvider: string;\n lastLoginAt: string | null;\n createdAt: string;\n}\n\n/** Membership entry for the profile page. */\ninterface ProfileMembership {\n projectName: string;\n projectSlug: string;\n role: string;\n source: string;\n}\n\nfunction field(form: FormData, name: string): string {\n const raw = form.get(name);\n return typeof raw === \"string\" ? raw : \"\";\n}\n\nfunction toDbUser(row: ProfileRow | null): ProfilePage[\"dbUser\"] {\n if (!row) {\n return undefined;\n }\n return {\n email: row.email,\n name: row.name,\n displayNameOverride: row.displayNameOverride,\n authProvider: row.authProvider,\n lastLoginAt: row.lastLoginAt,\n createdAt: row.createdAt,\n };\n}\n\ntype ProfilePage = Parameters<typeof renderProfilePage>[0];\n\nasync function loadProfileData(userId: string): Promise<{\n userRow: ProfileRow | null;\n memberships: ProfileMembership[];\n subscribedSlugs: string[];\n}> {\n const { db } = getStore();\n const userRow = (await new UserModel(db).get(userId)) as unknown as ProfileRow | null;\n const members = (await db.list(db.tables.projectMembers, {\n where: eq(getTableColumns(db.tables.projectMembers)[\"userId\"] as never, userId),\n })) as unknown as Array<{ projectId: string; role: string; source: string }>;\n const projects = (await db.list(db.tables.projects)) as unknown as Array<{\n id: string;\n name: string;\n slug: string;\n }>;\n const byId = new Map(projects.map((project) => [project.id, project]));\n const memberships = members.flatMap((member) => {\n const project = byId.get(member.projectId);\n return project\n ? [\n {\n projectName: project.name,\n projectSlug: project.slug,\n role: member.role,\n source: member.source,\n },\n ]\n : [];\n });\n const subs = await new NotificationSubscriptionModel(db).listForUser(userId);\n const subscribedIds = new Set(subs.filter((sub) => sub.enabled).map((sub) => sub.projectId));\n const subscribedSlugs = [...byId.values()]\n .filter((project) => subscribedIds.has(project.id))\n .map((project) => project.slug);\n return { userRow, memberships, subscribedSlugs };\n}\n\nfunction readSessionToken(c: Context): string {\n const header = c.req.header(\"cookie\") ?? \"\";\n for (const part of header.split(\";\")) {\n const eqIndex = part.indexOf(\"=\");\n if (eqIndex !== -1 && part.slice(0, eqIndex).trim() === SESSION_COOKIE) {\n // Engine cookies are signed (`token.signature`); the table holds the raw token.\n return unsignedToken(part.slice(eqIndex + 1).trim());\n }\n }\n return \"\";\n}\n\n/** Engine security overview for the profile page. */\nasync function loadEngineSecurity(\n c: Context,\n auth: Auth,\n user: AuthUser,\n): Promise<NonNullable<ProfilePage[\"security\"]>> {\n const [sessions, passkeys, hasPassword] = await Promise.all([\n auth.listSessions(user.id),\n auth.listPasskeys(user.id),\n auth.hasPassword(user.id),\n ]);\n const current = readSessionToken(c);\n return {\n sessions: sessions.map((session) => ({\n id: session.id,\n ipAddress: session.ipAddress,\n userAgent: session.userAgent,\n createdAt: session.createdAt,\n expiresAt: session.expiresAt,\n current: session.token === current,\n })),\n passkeys,\n passkeysEnabled: auth.passkeysEnabled(),\n hasPassword,\n };\n}\n\nasync function profileView(\n c: Context,\n user: AuthUser,\n auth: Auth,\n extra?: { error?: string; success?: string; local?: boolean; status?: 200 | 400 },\n): Promise<Response> {\n const { userRow, memberships, subscribedSlugs } = await loadProfileData(user.id);\n const security = await loadEngineSecurity(c, auth, user);\n const html = await renderProfilePage({\n user,\n dbUser: toDbUser(userRow),\n memberships,\n subscribedSlugs,\n isLocal: extra?.local ?? security.hasPassword,\n security,\n error: extra?.error,\n });\n if (extra?.success) {\n flash(c, extra.success);\n }\n // Device tokens render into the page (per-device revoke forms): never store.\n c.header(\"Cache-Control\", \"no-store\");\n return c.html(html, extra?.status ?? 200);\n}\n\n/** Register the personal profile page and its mutations. */\nexport function registerProfile(app: ShelfRouter, auth: Auth): void {\n app.get(\"/profile\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await profileView(c, user, auth);\n });\n\n app.post(\"/profile\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n const displayName = field(await c.req.formData(), \"displayName\").trim();\n if (!displayName) {\n return profileView(c, user, auth, { error: \"Display name is required\", status: 400 });\n }\n await new UserModel(getStore().db).setDisplayNameOverride(user.id, displayName);\n flash(c, \"Profile updated\");\n return hxRedirect(c, \"/profile\");\n });\n\n registerPasswordRoute(app, auth);\n registerSessionRoutes(app, auth);\n registerPasskeyRoutes(app, auth);\n registerNotificationsRoute(app, auth);\n}\n\nfunction registerNotificationsRoute(app: ShelfRouter, auth: Auth): void {\n app.post(\"/profile/notifications\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await saveProfileNotifications(c, user, auth);\n });\n}\n\n/** Toggle the viewer's own email subscription for one member project. */\nasync function saveProfileNotifications(c: Context, user: AuthUser, auth: Auth): Promise<Response> {\n const form = await c.req.formData();\n const project = await memberProject(field(form, \"slug\"), user.id);\n if (!project) {\n return profileView(c, user, auth, { error: \"Unknown project\", status: 400 });\n }\n await applyProfileToggle(project.id, user.id, field(form, \"enabled\") === \"1\");\n flash(c, \"Notification preference saved\");\n return hxRedirect(c, \"/profile\");\n}\n\n/** Resolve a project the viewer belongs to (null otherwise). */\nasync function memberProject(slug: string, userId: string): Promise<Project | null> {\n const project = await new ProjectModel(getStore().db).getBySlug(slug);\n if (!project) {\n return null;\n }\n const { memberships } = await loadProfileData(userId);\n const isMember = memberships.some((membership) => membership.projectSlug === slug);\n return isMember ? project : null;\n}\n\n/** Enable (opt-in email) or disable (remove) a subscription. */\nasync function applyProfileToggle(\n projectId: string,\n userId: string,\n enable: boolean,\n): Promise<void> {\n const model = new NotificationSubscriptionModel(getStore().db);\n if (enable) {\n await model.upsert(projectId, userId, { via: [\"email\"] });\n return;\n }\n await model.remove(projectId, userId);\n}\n\nfunction registerPasswordRoute(app: ShelfRouter, auth: Auth): void {\n app.post(\"/profile/password\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await handleEnginePasswordChange(c, auth, user);\n });\n}\n\nfunction registerSessionRoutes(app: ShelfRouter, auth: Auth): void {\n app.post(\"/profile/sessions/revoke\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await handleSessionRevoke(c, auth, user);\n });\n\n app.post(\"/profile/sessions/revoke-others\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await handleRevokeOthers(c, auth, user);\n });\n}\n\nfunction registerPasskeyRoutes(app: ShelfRouter, auth: Auth): void {\n app.post(\"/profile/passkeys/:passkeyId/delete\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await handlePasskeyDelete(c, auth, user);\n });\n}\n\nasync function handleSessionRevoke(c: Context, auth: Auth, user: AuthUser): Promise<Response> {\n const id = await readSessionId(c);\n if (!id) {\n return profileView(c, user, auth, { error: \"Missing session\", status: 400 });\n }\n const token = await resolveSessionToken(auth, user, id);\n if (!token) {\n return profileView(c, user, auth, { error: \"Could not revoke that session\", status: 400 });\n }\n const res = await forwardToEngine(c, auth, \"/api/auth/revoke-session\", { token });\n if (res.status !== 200) {\n return profileView(c, user, auth, { error: \"Could not revoke that session\", status: 400 });\n }\n return profileView(c, user, auth, { success: \"Session revoked\" });\n}\n\nasync function readSessionId(c: Context): Promise<string> {\n return field(await c.req.formData(), \"sessionId\");\n}\n\nasync function resolveSessionToken(auth: Auth, user: AuthUser, id: string): Promise<string | null> {\n const sessions = await auth.listSessions(user.id);\n return sessions.find((session) => session.id === id)?.token ?? null;\n}\n\nasync function handleRevokeOthers(c: Context, auth: Auth, user: AuthUser): Promise<Response> {\n const res = await forwardToEngine(c, auth, \"/api/auth/revoke-other-sessions\", {});\n if (res.status !== 200) {\n return profileView(c, user, auth, { error: \"Could not sign out other devices\", status: 400 });\n }\n return profileView(c, user, auth, { success: \"Signed out of other devices\" });\n}\n\nasync function handlePasskeyDelete(c: Context, auth: Auth, user: AuthUser): Promise<Response> {\n const id = c.req.param(\"passkeyId\") ?? \"\";\n if (!/^[A-Za-z0-9_-]{1,64}$/u.test(id)) {\n return profileView(c, user, auth, { error: \"Unknown passkey\", status: 400 });\n }\n const res = await forwardToEngine(c, auth, \"/api/auth/passkey/delete-passkey\", { id });\n if (res.status !== 200) {\n return profileView(c, user, auth, { error: \"Could not remove that passkey\", status: 400 });\n }\n return profileView(c, user, auth, { success: \"Passkey removed\" });\n}\n\nasync function engineErrorMessage(res: Response, fallback: string): Promise<string> {\n try {\n const body = (await res.json()) as { message?: unknown };\n return typeof body.message === \"string\" && body.message ? body.message : fallback;\n } catch {\n return fallback;\n }\n}\n\n/** Validate the password form and change it through the engine. */\nasync function handleEnginePasswordChange(\n c: Context,\n auth: Auth,\n user: AuthUser,\n): Promise<Response> {\n const { current, next, error } = readPasswordChange(await c.req.formData());\n if (error) {\n return profileView(c, user, auth, { error, local: true, status: 400 });\n }\n const res = await forwardToEngine(c, auth, \"/api/auth/change-password\", {\n currentPassword: current,\n newPassword: next,\n revokeOtherSessions: true,\n });\n if (res.status !== 200) {\n const message = await engineErrorMessage(res, \"Password change failed\");\n return profileView(c, user, auth, { error: message, local: true, status: 400 });\n }\n return profileView(c, user, auth, { success: \"Password changed\", local: true });\n}\n\nfunction readPasswordChange(form: FormData): { current: string; next: string; error?: string } {\n const next = field(form, \"newPassword\");\n if (next !== field(form, \"confirmPassword\")) {\n return { current: \"\", next: \"\", error: \"Passwords do not match\" };\n }\n if (next.length < MIN_PASSWORD_LENGTH) {\n return {\n current: \"\",\n next: \"\",\n error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters`,\n };\n }\n return { current: field(form, \"currentPassword\"), next };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"profile.mjs","names":[],"sources":["../../src/routers/profile.ts"],"sourcesContent":["import type { Auth } from \"@storyshelf/core/auth\";\nimport { MIN_PASSWORD_LENGTH, unsignedToken } from \"@storyshelf/core/auth\";\nimport { NotificationSubscriptionModel, ProjectModel, UserModel } from \"@storyshelf/core/models\";\nimport { eq, getTableColumns } from \"@storyshelf/core/orm\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport { SESSION_COOKIE, type AuthUser } from \"@storyshelf/core/types\";\nimport type { Context } from \"hono\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { renderProfilePage } from \"../pages/profile.tsx\";\nimport { getStore } from \"../store.ts\";\nimport { forwardToEngine } from \"./auth.ts\";\nimport { flash } from \"./flash.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\n\n/** User columns needed to render the profile page. */\ninterface ProfileRow {\n email: string;\n name: string;\n displayNameOverride: string | null;\n authProvider: string;\n lastLoginAt: string | null;\n createdAt: string;\n}\n\n/** Membership entry for the profile page. */\ninterface ProfileMembership {\n projectName: string;\n projectSlug: string;\n role: string;\n source: string;\n}\n\nfunction field(form: FormData, name: string): string {\n const raw = form.get(name);\n return typeof raw === \"string\" ? raw : \"\";\n}\n\nfunction toDbUser(row: ProfileRow | null): ProfilePage[\"dbUser\"] {\n if (!row) {\n return undefined;\n }\n return {\n email: row.email,\n name: row.name,\n displayNameOverride: row.displayNameOverride,\n authProvider: row.authProvider,\n lastLoginAt: row.lastLoginAt,\n createdAt: row.createdAt,\n };\n}\n\ntype ProfilePage = Parameters<typeof renderProfilePage>[0];\n\nasync function loadProfileData(userId: string): Promise<{\n userRow: ProfileRow | null;\n memberships: ProfileMembership[];\n subscribedSlugs: string[];\n}> {\n const { db } = getStore();\n const userRow = (await new UserModel(db).get(userId)) as unknown as ProfileRow | null;\n const members = (await db.list(db.tables.projectMembers, {\n where: eq(getTableColumns(db.tables.projectMembers)[\"userId\"] as never, userId),\n })) as unknown as Array<{ projectId: string; role: string; source: string }>;\n const projects = (await db.list(db.tables.projects)) as unknown as Array<{\n id: string;\n name: string;\n slug: string;\n }>;\n const byId = new Map(projects.map((project) => [project.id, project]));\n const memberships = members.flatMap((member) => {\n const project = byId.get(member.projectId);\n return project\n ? [\n {\n projectName: project.name,\n projectSlug: project.slug,\n role: member.role,\n source: member.source,\n },\n ]\n : [];\n });\n const subs = await new NotificationSubscriptionModel(db).listForUser(userId);\n const subscribedIds = new Set(subs.filter((sub) => sub.enabled).map((sub) => sub.projectId));\n const subscribedSlugs = [...byId.values()]\n .filter((project) => subscribedIds.has(project.id))\n .map((project) => project.slug);\n return { userRow, memberships, subscribedSlugs };\n}\n\nfunction readSessionToken(c: Context): string {\n const header = c.req.header(\"cookie\") ?? \"\";\n for (const part of header.split(\";\")) {\n const eqIndex = part.indexOf(\"=\");\n if (eqIndex !== -1 && part.slice(0, eqIndex).trim() === SESSION_COOKIE) {\n // Engine cookies are signed (`token.signature`); the table holds the raw token.\n return unsignedToken(part.slice(eqIndex + 1).trim());\n }\n }\n return \"\";\n}\n\n/** Engine security overview for the profile page. */\nasync function loadEngineSecurity(\n c: Context,\n auth: Auth,\n user: AuthUser,\n): Promise<NonNullable<ProfilePage[\"security\"]>> {\n const [sessions, passkeys, hasPassword] = await Promise.all([\n auth.listSessions(user.id),\n auth.listPasskeys(user.id),\n auth.hasPassword(user.id),\n ]);\n const current = readSessionToken(c);\n return {\n sessions: sessions.map((session) => ({\n id: session.id,\n ipAddress: session.ipAddress,\n userAgent: session.userAgent,\n createdAt: session.createdAt,\n expiresAt: session.expiresAt,\n current: session.token === current,\n })),\n passkeys,\n passkeysEnabled: auth.passkeysEnabled(),\n hasPassword,\n };\n}\n\nasync function profileView(\n c: Context,\n user: AuthUser,\n auth: Auth,\n extra?: { error?: string; success?: string; local?: boolean; status?: 200 | 400 },\n): Promise<Response> {\n const { userRow, memberships, subscribedSlugs } = await loadProfileData(user.id);\n const security = await loadEngineSecurity(c, auth, user);\n const html = await renderProfilePage({\n user,\n dbUser: toDbUser(userRow),\n memberships,\n subscribedSlugs,\n isLocal: extra?.local ?? security.hasPassword,\n security,\n error: extra?.error,\n });\n if (extra?.success) {\n flash(c, extra.success);\n }\n // Device tokens render into the page (per-device revoke forms): never store.\n c.header(\"Cache-Control\", \"no-store\");\n return c.html(html, extra?.status ?? 200);\n}\n\n/** Register the personal profile page and its mutations. */\nexport function registerProfile(app: ShelfRouter, auth: Auth): void {\n app.get(\"/profile\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await profileView(c, user, auth);\n });\n\n app.post(\"/profile\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n const displayName = field(await c.req.formData(), \"displayName\").trim();\n if (!displayName) {\n return profileView(c, user, auth, { error: \"Display name is required\", status: 400 });\n }\n await new UserModel(getStore().db).setDisplayNameOverride(user.id, displayName);\n flash(c, \"Profile updated\");\n return hxRedirect(c, \"/profile\");\n });\n\n registerPasswordRoute(app, auth);\n registerSessionRoutes(app, auth);\n registerPasskeyRoutes(app, auth);\n registerNotificationsRoute(app, auth);\n}\n\nfunction registerNotificationsRoute(app: ShelfRouter, auth: Auth): void {\n app.post(\"/profile/notifications\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await saveProfileNotifications(c, user, auth);\n });\n}\n\n/** Toggle the viewer's own email subscription for one member project. */\nasync function saveProfileNotifications(c: Context, user: AuthUser, auth: Auth): Promise<Response> {\n const form = await c.req.formData();\n const project = await memberProject(field(form, \"slug\"), user.id);\n if (!project) {\n return profileView(c, user, auth, { error: \"Unknown project\", status: 400 });\n }\n await applyProfileToggle(project.id, user.id, field(form, \"enabled\") === \"1\");\n flash(c, \"Notification preference saved\");\n return hxRedirect(c, \"/profile\");\n}\n\n/** Resolve a project the viewer belongs to (null otherwise). */\nasync function memberProject(slug: string, userId: string): Promise<Project | null> {\n const project = await new ProjectModel(getStore().db).getBySlug(slug);\n if (!project) {\n return null;\n }\n const { memberships } = await loadProfileData(userId);\n const isMember = memberships.some((membership) => membership.projectSlug === slug);\n return isMember ? project : null;\n}\n\n/** Enable (opt-in email) or disable (remove) a subscription. */\nasync function applyProfileToggle(\n projectId: string,\n userId: string,\n enable: boolean,\n): Promise<void> {\n const model = new NotificationSubscriptionModel(getStore().db);\n if (enable) {\n await model.upsert(projectId, userId, { via: [\"email\"] });\n return;\n }\n await model.remove(projectId, userId);\n}\n\nfunction registerPasswordRoute(app: ShelfRouter, auth: Auth): void {\n app.post(\"/profile/password\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await handleEnginePasswordChange(c, auth, user);\n });\n}\n\nfunction registerSessionRoutes(app: ShelfRouter, auth: Auth): void {\n app.post(\"/profile/sessions/revoke\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await handleSessionRevoke(c, auth, user);\n });\n\n app.post(\"/profile/sessions/revoke-others\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await handleRevokeOthers(c, auth, user);\n });\n}\n\nfunction registerPasskeyRoutes(app: ShelfRouter, auth: Auth): void {\n app.post(\"/profile/passkeys/:passkeyId/delete\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await handlePasskeyDelete(c, auth, user);\n });\n}\n\nasync function handleSessionRevoke(c: Context, auth: Auth, user: AuthUser): Promise<Response> {\n const id = await readSessionId(c);\n if (!id) {\n return profileView(c, user, auth, { error: \"Missing session\", status: 400 });\n }\n const token = await resolveSessionToken(auth, user, id);\n if (!token) {\n return profileView(c, user, auth, { error: \"Could not revoke that session\", status: 400 });\n }\n const res = await forwardToEngine(c, auth, \"/api/auth/revoke-session\", { token });\n if (res.status !== 200) {\n return profileView(c, user, auth, { error: \"Could not revoke that session\", status: 400 });\n }\n return profileView(c, user, auth, { success: \"Session revoked\" });\n}\n\nasync function readSessionId(c: Context): Promise<string> {\n return field(await c.req.formData(), \"sessionId\");\n}\n\nasync function resolveSessionToken(auth: Auth, user: AuthUser, id: string): Promise<string | null> {\n const sessions = await auth.listSessions(user.id);\n return sessions.find((session) => session.id === id)?.token ?? null;\n}\n\nasync function handleRevokeOthers(c: Context, auth: Auth, user: AuthUser): Promise<Response> {\n const res = await forwardToEngine(c, auth, \"/api/auth/revoke-other-sessions\", {});\n if (res.status !== 200) {\n return profileView(c, user, auth, { error: \"Could not sign out other devices\", status: 400 });\n }\n return profileView(c, user, auth, { success: \"Signed out of other devices\" });\n}\n\nasync function handlePasskeyDelete(c: Context, auth: Auth, user: AuthUser): Promise<Response> {\n const id = c.req.param(\"passkeyId\") ?? \"\";\n if (!/^[A-Za-z0-9_-]{1,64}$/u.test(id)) {\n return profileView(c, user, auth, { error: \"Unknown passkey\", status: 400 });\n }\n const res = await forwardToEngine(c, auth, \"/api/auth/passkey/delete-passkey\", { id });\n if (res.status !== 200) {\n return profileView(c, user, auth, { error: \"Could not remove that passkey\", status: 400 });\n }\n return profileView(c, user, auth, { success: \"Passkey removed\" });\n}\n\nasync function engineErrorMessage(res: Response, fallback: string): Promise<string> {\n try {\n const body = (await res.json()) as { message?: unknown };\n return typeof body.message === \"string\" && body.message ? body.message : fallback;\n } catch {\n return fallback;\n }\n}\n\n/** Validate the password form and change it through the engine. */\nasync function handleEnginePasswordChange(\n c: Context,\n auth: Auth,\n user: AuthUser,\n): Promise<Response> {\n const { current, next, error } = readPasswordChange(await c.req.formData());\n if (error) {\n return profileView(c, user, auth, { error, local: true, status: 400 });\n }\n const res = await forwardToEngine(c, auth, \"/api/auth/change-password\", {\n currentPassword: current,\n newPassword: next,\n revokeOtherSessions: true,\n });\n if (res.status !== 200) {\n const message = await engineErrorMessage(res, \"Password change failed\");\n return profileView(c, user, auth, { error: message, local: true, status: 400 });\n }\n return profileView(c, user, auth, { success: \"Password changed\", local: true });\n}\n\nfunction readPasswordChange(form: FormData): { current: string; next: string; error?: string } {\n const next = field(form, \"newPassword\");\n if (next !== field(form, \"confirmPassword\")) {\n return { current: \"\", next: \"\", error: \"Passwords do not match\" };\n }\n if (next.length < MIN_PASSWORD_LENGTH) {\n return {\n current: \"\",\n next: \"\",\n error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters`,\n };\n }\n return { current: field(form, \"currentPassword\"), next };\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"well-known.mjs","names":[],"sources":["../../src/routers/well-known.ts"],"sourcesContent":["import type { Auth } from \"@storyshelf/auth\";\nimport type { Context } from \"hono\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\n\n/** One SSO-capable login method with its IdP reply URLs. */\ninterface SsoProviderLink {\n id: string;\n label: string;\n redirectUris: string[];\n}\n\n/** SSO-capable login methods (oauth buttons + SSO) with IdP reply URLs. */\nfunction ssoProviders(auth: Auth, origin: string): SsoProviderLink[] {\n return auth\n .loginMethods()\n .filter((method) => method.kind === \"oauth\" || method.kind === \"sso\")\n .map((method) => ({\n id: method.id,\n label: method.label,\n redirectUris:\n method.kind === \"sso\"\n ? [\n `${origin}/api/auth/sso/callback/${method.id}`,\n `${origin}/api/auth/sso/saml2/sp/acs/${method.id}`,\n ]\n : [`${origin}/api/auth/callback/${method.id}`],\n }));\n}\n\nfunction requestOrigin(c: Context): string {\n return getStore().config.publicBaseUrl ?? new URL(c.req.url).origin;\n}\n\n/** Register public relying-party helper documents (no auth required). */\nexport function registerWellKnown(app: ShelfRouter, auth: Auth): void {\n app.get(\"/.well-known/openid-configuration\", (c) => {\n const origin = requestOrigin(c);\n const providers = ssoProviders(auth, origin);\n return c.json(\n {\n issuer: origin,\n note: \"StoryShelf is an OpenID Connect relying party, not an identity provider. This document helps operators register redirect URIs with their provider; it is not provider metadata.\",\n response_types_supported: [\"code\"],\n scopes_supported: [\"openid\", \"email\", \"profile\"],\n redirect_uris: providers.flatMap((provider) => provider.redirectUris),\n providers: providers.map((method) => ({ id: method.id, label: method.label })),\n },\n 200,\n { \"Cache-Control\": \"public, max-age=300\" },\n );\n });\n\n // oxlint-disable-next-line typescript/promise-function-async -- Hono handler may return Response directly\n app.get(\"/.well-known/change-password\", (c) => c.redirect(\"/profile\", 302));\n}\n"],"mappings":";;;;;;AAaA,SAAS,aAAa,MAAY,QAAmC;CACnE,OAAO,KACJ,aAAa,CAAC,CACd,QAAQ,WAAW,OAAO,SAAS,WAAW,OAAO,SAAS,KAAK,CAAC,CACpE,KAAK,YAAY;EAChB,IAAI,OAAO;EACX,OAAO,OAAO;EACd,cACE,OAAO,SAAS,QACZ,CACE,GAAG,OAAO,yBAAyB,OAAO,MAC1C,GAAG,OAAO,6BAA6B,OAAO,IAChD,IACA,CAAC,GAAG,OAAO,qBAAqB,OAAO,IAAI;CACnD,EAAE;AACN;AAEA,SAAS,cAAc,GAAoB;CACzC,OAAO,SAAS,CAAC,CAAC,OAAO,iBAAiB,IAAI,IAAI,EAAE,IAAI,GAAG,CAAC,CAAC;AAC/D;;AAGA,SAAgB,kBAAkB,KAAkB,MAAkB;CACpE,IAAI,IAAI,sCAAsC,MAAM;EAClD,MAAM,SAAS,cAAc,CAAC;EAC9B,MAAM,YAAY,aAAa,MAAM,MAAM;EAC3C,OAAO,EAAE,KACP;GACE,QAAQ;GACR,MAAM;GACN,0BAA0B,CAAC,MAAM;GACjC,kBAAkB;IAAC;IAAU;IAAS;GAAS;GAC/C,eAAe,UAAU,SAAS,aAAa,SAAS,YAAY;GACpE,WAAW,UAAU,KAAK,YAAY;IAAE,IAAI,OAAO;IAAI,OAAO,OAAO;GAAM,EAAE;EAC/E,GACA,KACA,EAAE,iBAAiB,sBAAsB,CAC3C;CACF,CAAC;CAGD,IAAI,IAAI,iCAAiC,MAAM,EAAE,SAAS,YAAY,GAAG,CAAC;AAC5E"}
|
|
1
|
+
{"version":3,"file":"well-known.mjs","names":[],"sources":["../../src/routers/well-known.ts"],"sourcesContent":["import type { Auth } from \"@storyshelf/core/auth\";\nimport type { Context } from \"hono\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\n\n/** One SSO-capable login method with its IdP reply URLs. */\ninterface SsoProviderLink {\n id: string;\n label: string;\n redirectUris: string[];\n}\n\n/** SSO-capable login methods (oauth buttons + SSO) with IdP reply URLs. */\nfunction ssoProviders(auth: Auth, origin: string): SsoProviderLink[] {\n return auth\n .loginMethods()\n .filter((method) => method.kind === \"oauth\" || method.kind === \"sso\")\n .map((method) => ({\n id: method.id,\n label: method.label,\n redirectUris:\n method.kind === \"sso\"\n ? [\n `${origin}/api/auth/sso/callback/${method.id}`,\n `${origin}/api/auth/sso/saml2/sp/acs/${method.id}`,\n ]\n : [`${origin}/api/auth/callback/${method.id}`],\n }));\n}\n\nfunction requestOrigin(c: Context): string {\n return getStore().config.publicBaseUrl ?? new URL(c.req.url).origin;\n}\n\n/** Register public relying-party helper documents (no auth required). */\nexport function registerWellKnown(app: ShelfRouter, auth: Auth): void {\n app.get(\"/.well-known/openid-configuration\", (c) => {\n const origin = requestOrigin(c);\n const providers = ssoProviders(auth, origin);\n return c.json(\n {\n issuer: origin,\n note: \"StoryShelf is an OpenID Connect relying party, not an identity provider. This document helps operators register redirect URIs with their provider; it is not provider metadata.\",\n response_types_supported: [\"code\"],\n scopes_supported: [\"openid\", \"email\", \"profile\"],\n redirect_uris: providers.flatMap((provider) => provider.redirectUris),\n providers: providers.map((method) => ({ id: method.id, label: method.label })),\n },\n 200,\n { \"Cache-Control\": \"public, max-age=300\" },\n );\n });\n\n // oxlint-disable-next-line typescript/promise-function-async -- Hono handler may return Response directly\n app.get(\"/.well-known/change-password\", (c) => c.redirect(\"/profile\", 302));\n}\n"],"mappings":";;;;;;AAaA,SAAS,aAAa,MAAY,QAAmC;CACnE,OAAO,KACJ,aAAa,CAAC,CACd,QAAQ,WAAW,OAAO,SAAS,WAAW,OAAO,SAAS,KAAK,CAAC,CACpE,KAAK,YAAY;EAChB,IAAI,OAAO;EACX,OAAO,OAAO;EACd,cACE,OAAO,SAAS,QACZ,CACE,GAAG,OAAO,yBAAyB,OAAO,MAC1C,GAAG,OAAO,6BAA6B,OAAO,IAChD,IACA,CAAC,GAAG,OAAO,qBAAqB,OAAO,IAAI;CACnD,EAAE;AACN;AAEA,SAAS,cAAc,GAAoB;CACzC,OAAO,SAAS,CAAC,CAAC,OAAO,iBAAiB,IAAI,IAAI,EAAE,IAAI,GAAG,CAAC,CAAC;AAC/D;;AAGA,SAAgB,kBAAkB,KAAkB,MAAkB;CACpE,IAAI,IAAI,sCAAsC,MAAM;EAClD,MAAM,SAAS,cAAc,CAAC;EAC9B,MAAM,YAAY,aAAa,MAAM,MAAM;EAC3C,OAAO,EAAE,KACP;GACE,QAAQ;GACR,MAAM;GACN,0BAA0B,CAAC,MAAM;GACjC,kBAAkB;IAAC;IAAU;IAAS;GAAS;GAC/C,eAAe,UAAU,SAAS,aAAa,SAAS,YAAY;GACpE,WAAW,UAAU,KAAK,YAAY;IAAE,IAAI,OAAO;IAAI,OAAO,OAAO;GAAM,EAAE;EAC/E,GACA,KACA,EAAE,iBAAiB,sBAAsB,CAC3C;CACF,CAAC;CAGD,IAAI,IAAI,iCAAiC,MAAM,EAAE,SAAS,YAAY,GAAG,CAAC;AAC5E"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@storyshelf/app",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"description": "StoryShelf app: Hono app, API routes, and server-rendered UI over @storyshelf/core.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"chromatic-alternative",
|
|
@@ -27,6 +27,9 @@
|
|
|
27
27
|
"dist"
|
|
28
28
|
],
|
|
29
29
|
"type": "module",
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=24"
|
|
32
|
+
},
|
|
30
33
|
"sideEffects": false,
|
|
31
34
|
"types": "./dist/index.d.mts",
|
|
32
35
|
"exports": {
|
|
@@ -57,8 +60,8 @@
|
|
|
57
60
|
"dependencies": {
|
|
58
61
|
"@hono/swagger-ui": "^0.6.1",
|
|
59
62
|
"@hono/zod-openapi": "^1.1.5",
|
|
60
|
-
"@storyshelf/core": "0.6.
|
|
61
|
-
"@storyshelf/observability": "0.6.
|
|
63
|
+
"@storyshelf/core": "0.6.2",
|
|
64
|
+
"@storyshelf/observability": "0.6.2",
|
|
62
65
|
"hono": "^4.11.0",
|
|
63
66
|
"zod": "^4.2.0"
|
|
64
67
|
},
|
|
@@ -67,8 +70,8 @@
|
|
|
67
70
|
"@opentelemetry/context-async-hooks": "^2.11.0",
|
|
68
71
|
"@opentelemetry/core": "^2.11.0",
|
|
69
72
|
"@opentelemetry/sdk-trace-base": "^2.11.0",
|
|
70
|
-
"@storyshelf/auth": "0.6.
|
|
71
|
-
"@storyshelf/db-sqlite": "0.6.
|
|
73
|
+
"@storyshelf/auth": "0.6.2",
|
|
74
|
+
"@storyshelf/db-sqlite": "0.6.2",
|
|
72
75
|
"@types/node": "^26.0.0",
|
|
73
76
|
"@vitest/coverage-v8": "^4.0.0",
|
|
74
77
|
"adm-zip": "0.6.0",
|