@ima-jin/config 0.8.0 → 0.8.13

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/index.d.ts CHANGED
@@ -12,6 +12,39 @@ declare function corsOptions(request: NextRequest): NextResponse;
12
12
  declare function withCors(response: NextResponse, request: NextRequest): NextResponse;
13
13
  declare function validateOrigin(request: NextRequest): boolean;
14
14
 
15
+ interface StandaloneDashboardMiddlewareOptions {
16
+ /** Service name used to build the hub tab URL (`/auth/<service>`). */
17
+ service: string;
18
+ /**
19
+ * Whether to also answer CORS preflight and stamp CORS headers on every
20
+ * other request — matches the app's pre-existing `/api/:path*` CORS
21
+ * middleware (#1525). Defaults to `true`; pass `false` for an app (e.g.
22
+ * dykil) that never had one.
23
+ */
24
+ cors?: boolean;
25
+ }
26
+ /**
27
+ * Shared standalone-dashboard-to-hub redirect body (#2332, carved out of
28
+ * #2275), used by every userspace service app's `middleware.ts` so the
29
+ * redirect + CORS pass-through logic isn't duplicated six times over.
30
+ *
31
+ * Each app still declares its own literal `export const config = { matcher:
32
+ * [...] }` — Next.js statically extracts `matcher` from the middleware
33
+ * file's AST at build time (see
34
+ * `next/dist/build/analysis/extract-const-value.js`: it only understands
35
+ * literal object/array/string/etc. expressions, not a function call or a
36
+ * destructured import) — so the matcher itself can't be factored out here
37
+ * without Next silently falling back to its default, unrestricted matcher.
38
+ * Only the function *body* is shared.
39
+ *
40
+ * The redirect is unconditional: a 308 to `/auth/<service>`, preserving the
41
+ * query string. The embedded rendering path `<ServiceEmbed>` loads
42
+ * (`?embed=hub&did=...`, see `apps/kernel/app/auth/lib/service-registry.ts`'s
43
+ * `buildEmbedSrc`) never reaches this function at all — it's excluded
44
+ * declaratively by each app's `matcher.missing` clause.
45
+ */
46
+ declare function standaloneDashboardMiddleware(request: NextRequest, { service, cors }: StandaloneDashboardMiddlewareOptions): NextResponse;
47
+
15
48
  type HeaderEntry = {
16
49
  key: string;
17
50
  value: string;
@@ -35,9 +68,32 @@ declare function tier2Headers(): HeaderEntry[];
35
68
  declare function tier3Headers(): HeaderEntry[];
36
69
 
37
70
  /**
38
- * Canonical service manifest — single source of truth for all Imajin services.
71
+ * Human-facing product display name.
72
+ *
73
+ * This is the ONLY place the "(α)" (alpha) stage marker lives — flip it here
74
+ * (e.g. to "(β)" or drop it for GA) and every consumer (BuildInfo, nav bars,
75
+ * page titles/metadata, auth screens, email templates, etc.) picks it up.
39
76
  *
40
- * Consumers: registry specs, Caddy config, pm2 ecosystem, docs sync, shared nav.
77
+ * Do NOT use this for DIDs, URLs, package names, event names, localStorage
78
+ * keys, env vars, or any other code identifier — those stay as literal
79
+ * 'imajin' strings. This constant is display-text only.
80
+ */
81
+ declare const APP_DISPLAY_NAME = "imajin (\u03B1)";
82
+
83
+ /**
84
+ * Canonical service manifest — infra/build-time topology for all Imajin
85
+ * services (dev/prod ports, Postgres schema, deployment tier, Caddy/pm2
86
+ * routing, docs sync, `/registry/api/specs`). These consumers run without a
87
+ * live identity/DB context (build scripts, edge config, port-based
88
+ * routing), so this stays a static, synchronously-importable array.
89
+ *
90
+ * NOT the nav authority (#2425): which apps an authenticated identity sees
91
+ * in the kernel hub (auth submenu, launcher, home tiles) is now derived
92
+ * from `registry.apps` (#1990) via `apps/kernel/src/lib/kernel/app-nav.ts`,
93
+ * not from this file — a disabled/pruned app disappears from nav by
94
+ * flipping registry state, independent of whether it still has a row here
95
+ * for port/Caddy purposes. Consumers: registry specs, Caddy config, pm2
96
+ * ecosystem, docs sync.
41
97
  */
42
98
  type ServiceTier = "core" | "imajin";
43
99
  type ServiceVisibility = "public" | "authenticated" | "creator" | "internal";
@@ -138,6 +194,9 @@ declare const SESSION_COOKIE_NAME: string;
138
194
  *
139
195
  * Localhost-aware: when SERVICE_PREFIX contains "localhost", uses
140
196
  * settings compatible with HTTP on localhost (no domain, not secure, lax sameSite).
197
+ *
198
+ * See {@link isHostScoped} for the `SESSION_COOKIE_SCOPE=host` opt-in that
199
+ * narrows the deployed cookie to host-only instead of `.imajin.ai`.
141
200
  */
142
201
  declare function getSessionCookieOptions(env?: "dev" | "prod"): {
143
202
  name: string;
@@ -189,17 +248,17 @@ declare function apiUrl(path: string): string;
189
248
  *
190
249
  * Absolute URLs (http://, https://) are passed through unchanged.
191
250
  *
192
- * TODO(WO2/correlation): For server-side service-to-service calls, callers should
193
- * forward the X-Correlation-Id header so the full request chain shares one ID.
194
- * apiFetch is browser-side too, so we can't read headers here automatically —
195
- * instead, pass it explicitly via init.headers when calling from a withLogger handler:
251
+ * For server-side service-to-service calls, forward the X-Correlation-Id header
252
+ * so the full request chain shares one ID. apiFetch is browser-side too, so it
253
+ * can't read headers here automatically — pass it explicitly via init.headers
254
+ * when calling from a withLogger handler:
196
255
  *
197
256
  * apiFetch('/api/something', {
198
257
  * headers: { 'x-correlation-id': correlationId },
199
258
  * });
200
259
  *
201
- * When WO3 instruments services, each withLogger handler receives correlationId in
202
- * ctx and should forward it on any outbound apiFetch/fetch calls.
260
+ * see: automatic correlationId forwarding on outbound apiFetch/fetch calls from
261
+ * withLogger handlers is not yet wired up.
203
262
  */
204
263
  declare function apiFetch(path: string, init?: RequestInit): Promise<Response>;
205
264
 
@@ -213,6 +272,89 @@ declare function rateLimit(key: string, limit: number, windowMs: number): {
213
272
  };
214
273
  declare function getClientIP(request: Request): string;
215
274
 
275
+ /**
276
+ * Minimal public node metadata returned by GET /registry/api/node/self.
277
+ * Mirrors the subset of `relay.relay_config` the registry service exposes
278
+ * publicly — never the private config (profile artifact JWS, etc).
279
+ */
280
+ interface NodeSelfInfo {
281
+ /** This node's own DID (did:imajin:...). */
282
+ did: string;
283
+ /** DID of the human/entity operating this node, if configured. */
284
+ nodeOperatorDid: string | null;
285
+ /** Node fee, in basis points, applied to .fair settlements on this node. */
286
+ nodeFeeBps: number | null;
287
+ /** Buyer credit, in basis points, applied to .fair settlements on this node. */
288
+ buyerCreditBps: number | null;
289
+ }
290
+ /**
291
+ * Fetch this node's public identity + .fair fee config from the registry
292
+ * service (`GET /registry/api/node/self`, #2000).
293
+ *
294
+ * Replaces raw `relay.relay_config` SQL reads that were duplicated across
295
+ * coffee, learn, events, and market (audit item 8 of #1983). Returns null on
296
+ * any failure — network error, non-2xx response (e.g. 503 when the node
297
+ * identity hasn't been bootstrapped yet) — so callers can fall back to
298
+ * defaults exactly as they did when the raw SQL row was missing. A non-2xx
299
+ * response is logged with the URL actually hit (no secrets) so a
300
+ * misconfigured REGISTRY_SERVICE_URL prefix is visible instead of failing
301
+ * silently (#2046).
302
+ */
303
+ declare function getNodeSelf(): Promise<NodeSelfInfo | null>;
304
+
305
+ /**
306
+ * Single resolver for the registry service's base URL (#2061 — kernel was
307
+ * the last `REGISTRY_URL` holdout after #2048 established the
308
+ * `*_SERVICE_URL` convention).
309
+ *
310
+ * The returned value always includes the `/registry` path prefix, like
311
+ * every other `*_SERVICE_URL` (#2046) — callers append only the endpoint
312
+ * path (e.g. `/api/node/self`, `/relay/operations`).
313
+ *
314
+ * Precedence:
315
+ * 1. `REGISTRY_SERVICE_URL` — the current convention.
316
+ * 2. `REGISTRY_URL` — deprecated kernel-only legacy name. Used if set,
317
+ * but logs a one-time deprecation warning per process.
318
+ * 3. `http://localhost:${PORT || 3000}/registry` — the mode-aware
319
+ * fallback (PORT is 3000 in dev, 7000 in prod) that
320
+ * `relay-well-known.ts` already used before this consolidation.
321
+ */
322
+ declare function registryServiceUrl(): string;
323
+ /**
324
+ * True when the registry URL is explicitly configured via either the
325
+ * current (`REGISTRY_SERVICE_URL`) or deprecated (`REGISTRY_URL`) env var.
326
+ *
327
+ * `registryServiceUrl()` always returns a value (it falls back to a
328
+ * localhost guess), so callers that previously guarded on
329
+ * `!process.env.REGISTRY_URL` to skip/no-op when the registry isn't
330
+ * configured should check this instead.
331
+ */
332
+ declare function hasRegistryServiceUrl(): boolean;
333
+
334
+ /**
335
+ * Public forest-group (scope) config exposed by
336
+ * `GET /api/forest/{groupDid}/config/public` on the profile service. Only
337
+ * the field relevant to `.fair` fee-split calculation is modeled here — the
338
+ * rest of the config (enabledServices, landingService, etc.) is a
339
+ * community-settings concern, not a shared SDK concern.
340
+ */
341
+ interface ForestScopeConfig {
342
+ /** Group's configured revenue-share fee, in basis points (0-500). Null if unset. */
343
+ scopeFeeBps: number | null;
344
+ }
345
+ /**
346
+ * Fetch a forest group's public scope-fee config from the profile service
347
+ * (`GET /api/forest/{groupDid}/config/public`, documented in
348
+ * `apps/kernel/api-spec/profile.yaml`).
349
+ *
350
+ * Replaces raw `profile.forest_config` SQL reads that were duplicated
351
+ * across coffee, learn, and market (audit item 9 of #1983, #2001). Returns
352
+ * null on any failure — network error, non-2xx response — so callers can
353
+ * fall back to the same `scopeFeeBps: null` default they used when the raw
354
+ * SQL row was missing.
355
+ */
356
+ declare function getForestScopeConfig(groupDid: string): Promise<ForestScopeConfig | null>;
357
+
216
358
  /**
217
359
  * Handle validation and normalization.
218
360
  *
@@ -223,7 +365,7 @@ declare function getClientIP(request: Request): string;
223
365
  /** Regex: the full allowed character set, length enforced */
224
366
  declare const HANDLE_PATTERN: RegExp;
225
367
  /** HTML input pattern attribute (no anchors, no flags) */
226
- declare const HANDLE_INPUT_PATTERN = "[a-z0-9._\\-]{3,30}";
368
+ declare const HANDLE_INPUT_PATTERN: string;
227
369
  /** Characters allowed in handles — use in onChange filters */
228
370
  declare const HANDLE_ALLOWED_CHARS: RegExp;
229
371
  /** Reserved handles that cannot be claimed */
@@ -340,4 +482,4 @@ declare const projectPath: () => string;
340
482
  declare const notifyPath: () => string;
341
483
  declare const notifySettingsPath: () => string;
342
484
 
343
- export { ALLOWED_FRAME_ORIGINS, HANDLE_ALLOWED_CHARS, HANDLE_ERROR, HANDLE_INPUT_PATTERN, HANDLE_PATTERN, RESERVED_HANDLES, SERVICES, SERVICE_NAMES, SESSION_COOKIE_NAME, type ServiceCategory, type ServiceDefinition, type ServiceTier, type ServiceVisibility, apiFetch, apiUrl, articleAuthorPath, articlePath, articlesPath, authAgentsPath, authAppsPath, authAttestationsPath, authAuthorizePath, authDeveloperAppPath, authDeveloperAppsPath, authGroupSettingsPath, authGroupsPath, authLoginPath, authMembersPath, authNewGroupPath, authNewStubPath, authNotificationsPath, authOnboardPath, authRegisterPath, authSecurityPath, authSettingsPath, authStubsPath, bugsAdminPath, bugsPath, buildPath, buildPublicUrl, buildPublicUrlAbsolute, buildServiceUrlMap, bumpPath, chatConversationPath, chatConversationsPath, chatPath, coffeeDashboardPath, coffeeEditPath, coffeeHandlePath, coffeeSuccessPath, connectionsInvitePath, connectionsPath, connectionsPodPath, corsHeaders, corsOptions, developerGuidePath, docsPath, dykilCreatePath, dykilDashboardPath, dykilEmbedPath, dykilHandlePath, dykilResultsPath, dykilSurveyPath, eventAdminPath, eventCheckoutSuccessPath, eventEditPath, eventEditUrl, eventMyTicketsPath, eventMyTicketsUrl, eventPath, eventRegisterPath, eventRegisterUrl, eventUrl, eventsAdminListPath, eventsCheckoutSuccessPath, eventsCreatePath, eventsDashboardPath, getClientIP, getPort, getPublicUrl, getService, getServiceUrl, getSessionCookieName, getSessionCookieOptions, healthPath, isAllowedOrigin, isReservedHandle, isValidHandle, learnCourseDashboardPath, learnCourseLessonPath, learnCoursePath, learnCoursePresentPath, learnCourseStudentsPath, learnDashboardPath, learnHandlePath, linksDashboardPath, linksEditPath, linksHandlePath, marketCheckoutSuccessPath, marketDashboardPath, marketListingEditPath, marketListingPath, marketNewListingPath, marketSellerPath, marketSettingsPath, mediaPath, normalizeHandleInput, notifyPath, notifySettingsPath, payHistoryPath, payPath, payPayoutsPath, payTopupPath, payTopupSuccessPath, privacyPath, profileEditPath, profileLoginPath, profilePath, profileRegisterPath, profileUrl, projectPath, rateLimit, registryDocsPath, registryPath, servicesByTier, servicesByVisibility, subscribePath, tier1Headers, tier2Headers, tier3Headers, validateOrigin, whitepaperPath, withCors };
485
+ export { ALLOWED_FRAME_ORIGINS, APP_DISPLAY_NAME, type ForestScopeConfig, HANDLE_ALLOWED_CHARS, HANDLE_ERROR, HANDLE_INPUT_PATTERN, HANDLE_PATTERN, type NodeSelfInfo, RESERVED_HANDLES, SERVICES, SERVICE_NAMES, SESSION_COOKIE_NAME, type ServiceCategory, type ServiceDefinition, type ServiceTier, type ServiceVisibility, type StandaloneDashboardMiddlewareOptions, apiFetch, apiUrl, articleAuthorPath, articlePath, articlesPath, authAgentsPath, authAppsPath, authAttestationsPath, authAuthorizePath, authDeveloperAppPath, authDeveloperAppsPath, authGroupSettingsPath, authGroupsPath, authLoginPath, authMembersPath, authNewGroupPath, authNewStubPath, authNotificationsPath, authOnboardPath, authRegisterPath, authSecurityPath, authSettingsPath, authStubsPath, bugsAdminPath, bugsPath, buildPath, buildPublicUrl, buildPublicUrlAbsolute, buildServiceUrlMap, bumpPath, chatConversationPath, chatConversationsPath, chatPath, coffeeDashboardPath, coffeeEditPath, coffeeHandlePath, coffeeSuccessPath, connectionsInvitePath, connectionsPath, connectionsPodPath, corsHeaders, corsOptions, developerGuidePath, docsPath, dykilCreatePath, dykilDashboardPath, dykilEmbedPath, dykilHandlePath, dykilResultsPath, dykilSurveyPath, eventAdminPath, eventCheckoutSuccessPath, eventEditPath, eventEditUrl, eventMyTicketsPath, eventMyTicketsUrl, eventPath, eventRegisterPath, eventRegisterUrl, eventUrl, eventsAdminListPath, eventsCheckoutSuccessPath, eventsCreatePath, eventsDashboardPath, getClientIP, getForestScopeConfig, getNodeSelf, getPort, getPublicUrl, getService, getServiceUrl, getSessionCookieName, getSessionCookieOptions, hasRegistryServiceUrl, healthPath, isAllowedOrigin, isReservedHandle, isValidHandle, learnCourseDashboardPath, learnCourseLessonPath, learnCoursePath, learnCoursePresentPath, learnCourseStudentsPath, learnDashboardPath, learnHandlePath, linksDashboardPath, linksEditPath, linksHandlePath, marketCheckoutSuccessPath, marketDashboardPath, marketListingEditPath, marketListingPath, marketNewListingPath, marketSellerPath, marketSettingsPath, mediaPath, normalizeHandleInput, notifyPath, notifySettingsPath, payHistoryPath, payPath, payPayoutsPath, payTopupPath, payTopupSuccessPath, privacyPath, profileEditPath, profileLoginPath, profilePath, profileRegisterPath, profileUrl, projectPath, rateLimit, registryDocsPath, registryPath, registryServiceUrl, servicesByTier, servicesByVisibility, standaloneDashboardMiddleware, subscribePath, tier1Headers, tier2Headers, tier3Headers, validateOrigin, whitepaperPath, withCors };
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ function corsHeaders(request) {
16
16
  "Access-Control-Allow-Origin": allowed ? origin : "",
17
17
  "Access-Control-Allow-Credentials": "true",
18
18
  "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
19
- "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Caller-DID",
19
+ "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Caller-DID, X-App-DID, X-App-Authorization",
20
20
  "Vary": "Origin"
21
21
  };
22
22
  }
@@ -36,26 +36,8 @@ function validateOrigin(request) {
36
36
  return isAllowedOrigin(origin);
37
37
  }
38
38
 
39
- // src/security-headers.ts
40
- function buildFrameAncestors() {
41
- const local = process.env.NODE_ENV !== "production" ? " http://localhost:*" : "";
42
- return `frame-ancestors 'self' ${ALLOWED_FRAME_ORIGINS}${local}`;
43
- }
44
- function tier1Headers() {
45
- return [
46
- { key: "Content-Security-Policy", value: "frame-ancestors 'none'" },
47
- { key: "X-Frame-Options", value: "DENY" }
48
- ];
49
- }
50
- function tier2Headers() {
51
- return [{ key: "Content-Security-Policy", value: buildFrameAncestors() }];
52
- }
53
- function tier3Headers() {
54
- return [
55
- { key: "X-Content-Type-Options", value: "nosniff" },
56
- { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }
57
- ];
58
- }
39
+ // src/dashboard-redirect-middleware.ts
40
+ import { NextResponse as NextResponse2 } from "next/server";
59
41
 
60
42
  // src/services.ts
61
43
  var SERVICES = [
@@ -69,6 +51,11 @@ var SERVICES = [
69
51
  { name: "media", label: "Media", icon: "\u{1F4C1}", description: "Files, images, and .fair attribution", devPort: 3e3, prodPort: 7e3, schema: "media", tier: "core", visibility: "authenticated", category: "kernel" },
70
52
  { name: "registry", label: "Registry", icon: "\u{1F4E1}", description: "Service registry and DFOS relay", devPort: 3e3, prodPort: 7e3, schema: "registry", tier: "core", visibility: "public", category: "kernel" },
71
53
  { name: "notify", label: "Notify", icon: "\u{1F514}", description: "Notifications and preferences", devPort: 3e3, prodPort: 7e3, schema: "notify", tier: "core", visibility: "internal", category: "kernel" },
54
+ { name: "usage", label: "Usage", icon: "\u{1F4CA}", description: "Inference cost ledger \u2014 incurred, billed, drift, signed rollup", devPort: 3e3, prodPort: 7e3, schema: "usage", tier: "core", visibility: "authenticated", category: "kernel" },
55
+ { name: "calendar", label: "Calendar", icon: "\u{1F4C5}", description: "Temporal entries \u2014 availability, meetings, events, and reminders", devPort: 3e3, prodPort: 7e3, schema: null, tier: "core", visibility: "authenticated", category: "kernel" },
56
+ { name: "jin", label: "Jin", icon: "\u{1F9ED}", description: "Operator dashboard \u2014 signed approvals, proposals, and usage feed", devPort: 3e3, prodPort: 7e3, schema: "operator", tier: "core", visibility: "internal", category: "kernel" },
57
+ { name: "google", label: "Google Workspace", icon: "\u{1F4E8}", description: "Gmail/Calendar/Drive/Meet connector \u2014 OAuth2 auth-code, sealed refresh token", devPort: 3e3, prodPort: 7e3, schema: null, tier: "core", visibility: "authenticated", category: "kernel" },
58
+ { name: "vault", label: "Vault", icon: "\u{1F510}", description: "Sealed secrets, delegation grants, and vault-minted service credentials", devPort: 3e3, prodPort: 7e3, schema: null, tier: "core", visibility: "internal", category: "kernel" },
72
59
  // Core apps — separate processes
73
60
  { name: "events", label: "Events", icon: "\u{1F3AB}", description: "Event creation, ticketing, and management", devPort: 3006, prodPort: 7006, schema: "events", tier: "core", visibility: "public", category: "core" },
74
61
  // Imajin apps
@@ -82,7 +69,8 @@ var SERVICES = [
82
69
  { name: "github", label: "GitHub", icon: "\u{1F419}", description: "Source code", devPort: 3e3, prodPort: 7e3, schema: "public", tier: "core", visibility: "public", category: "meta", externalUrl: "https://github.com/ima-jin/imajin-ai" },
83
70
  { name: "docs", label: "Docs", icon: "\u{1F4C4}", description: "API documentation", devPort: 3e3, prodPort: 7e3, schema: "public", tier: "core", visibility: "public", category: "meta", wwwPath: "/developer-guide" },
84
71
  // Daemon processes — not web apps, no HTTP port exposed
85
- { name: "broker-agent", label: "Broker Agent", icon: "\u{1F916}", description: "Telegram broker agent \u2014 conversational surface for social coordination (#1101)", devPort: 0, prodPort: 0, schema: null, tier: "core", visibility: "internal", category: "infrastructure" }
72
+ { name: "broker-agent", label: "Broker Agent", icon: "\u{1F916}", description: "Telegram broker agent \u2014 conversational surface for social coordination (#1101)", devPort: 0, prodPort: 0, schema: null, tier: "core", visibility: "internal", category: "infrastructure" },
73
+ { name: "corpus", label: "Corpus", icon: "\u{1F50E}", description: "Per-DID corpus indexing and BM25 search service (#1737)", devPort: 8013, prodPort: 8013, schema: null, tier: "core", visibility: "internal", category: "infrastructure" }
86
74
  // Connected apps (separate repos) will use the plugin architecture (#249).
87
75
  // Not included here — they authenticate via delegated sessions, not the monorepo manifest.
88
76
  ];
@@ -168,6 +156,46 @@ function buildServiceUrlMap(env, mode = "dev") {
168
156
  return map;
169
157
  }
170
158
 
159
+ // src/dashboard-redirect-middleware.ts
160
+ function standaloneDashboardMiddleware(request, { service, cors = true }) {
161
+ if (request.nextUrl.pathname === "/dashboard") {
162
+ const hubUrl = new URL(`${buildPublicUrlAbsolute("kernel")}/auth/${service}`);
163
+ hubUrl.search = request.nextUrl.search;
164
+ return NextResponse2.redirect(hubUrl, 308);
165
+ }
166
+ if (!cors) {
167
+ return NextResponse2.next();
168
+ }
169
+ if (request.method === "OPTIONS") {
170
+ return new NextResponse2(null, { status: 204, headers: corsHeaders(request) });
171
+ }
172
+ return withCors(NextResponse2.next(), request);
173
+ }
174
+
175
+ // src/security-headers.ts
176
+ function buildFrameAncestors() {
177
+ const local = process.env.NODE_ENV !== "production" ? " http://localhost:*" : "";
178
+ return `frame-ancestors 'self' ${ALLOWED_FRAME_ORIGINS}${local}`;
179
+ }
180
+ function tier1Headers() {
181
+ return [
182
+ { key: "Content-Security-Policy", value: "frame-ancestors 'none'" },
183
+ { key: "X-Frame-Options", value: "DENY" }
184
+ ];
185
+ }
186
+ function tier2Headers() {
187
+ return [{ key: "Content-Security-Policy", value: buildFrameAncestors() }];
188
+ }
189
+ function tier3Headers() {
190
+ return [
191
+ { key: "X-Content-Type-Options", value: "nosniff" },
192
+ { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }
193
+ ];
194
+ }
195
+
196
+ // src/brand.ts
197
+ var APP_DISPLAY_NAME = "imajin (\u03B1)";
198
+
171
199
  // src/session.ts
172
200
  function getSessionCookieName(env) {
173
201
  const resolved = env ?? (typeof process !== "undefined" && (process.env.IMAJIN_ENV === "dev" || process.env.NODE_ENV === "development") ? "dev" : "prod");
@@ -179,8 +207,13 @@ function isLocalhost() {
179
207
  const prefix = process.env.NEXT_PUBLIC_SERVICE_PREFIX ?? "";
180
208
  return prefix.includes("localhost");
181
209
  }
210
+ function isHostScoped() {
211
+ if (typeof process === "undefined") return false;
212
+ return process.env.SESSION_COOKIE_SCOPE === "host";
213
+ }
182
214
  function getSessionCookieOptions(env) {
183
215
  const local = isLocalhost();
216
+ const omitDomain = local || isHostScoped();
184
217
  return {
185
218
  name: getSessionCookieName(env),
186
219
  options: {
@@ -188,7 +221,7 @@ function getSessionCookieOptions(env) {
188
221
  secure: !local,
189
222
  sameSite: local ? "lax" : "none",
190
223
  path: "/",
191
- ...local ? {} : { domain: ".imajin.ai" },
224
+ ...omitDomain ? {} : { domain: ".imajin.ai" },
192
225
  maxAge: 60 * 60 * 24
193
226
  // 24 hours
194
227
  }
@@ -247,11 +280,67 @@ function getClientIP(request) {
247
280
  return "127.0.0.1";
248
281
  }
249
282
 
283
+ // src/registry-service-url.ts
284
+ var warnedAboutLegacyRegistryUrl = false;
285
+ function registryServiceUrl() {
286
+ if (process.env.REGISTRY_SERVICE_URL) return process.env.REGISTRY_SERVICE_URL;
287
+ if (process.env.REGISTRY_URL) {
288
+ if (!warnedAboutLegacyRegistryUrl) {
289
+ console.warn("REGISTRY_URL is deprecated \u2014 set REGISTRY_SERVICE_URL");
290
+ warnedAboutLegacyRegistryUrl = true;
291
+ }
292
+ return process.env.REGISTRY_URL;
293
+ }
294
+ return `http://localhost:${process.env.PORT || 3e3}/registry`;
295
+ }
296
+ function hasRegistryServiceUrl() {
297
+ return Boolean(process.env.REGISTRY_SERVICE_URL || process.env.REGISTRY_URL);
298
+ }
299
+
300
+ // src/node-self.ts
301
+ async function getNodeSelf() {
302
+ const url = `${registryServiceUrl()}/api/node/self`;
303
+ try {
304
+ const res = await fetch(url);
305
+ if (!res.ok) {
306
+ console.warn(`[config] getNodeSelf: registry returned ${res.status} for ${url} \u2014 check REGISTRY_SERVICE_URL`);
307
+ return null;
308
+ }
309
+ return await res.json();
310
+ } catch (err) {
311
+ console.warn(`[config] getNodeSelf: fetch failed for ${url} \u2014 ${err instanceof Error ? err.message : String(err)}`);
312
+ return null;
313
+ }
314
+ }
315
+
316
+ // src/forest-config.ts
317
+ function profileBaseUrl() {
318
+ if (process.env.PROFILE_SERVICE_URL) return process.env.PROFILE_SERVICE_URL;
319
+ const mode = process.env.NODE_ENV === "production" ? "prod" : "dev";
320
+ const base = getServiceUrl("profile", mode) ?? "http://localhost:3000";
321
+ return `${base}/profile`;
322
+ }
323
+ async function getForestScopeConfig(groupDid) {
324
+ const url = `${profileBaseUrl()}/api/forest/${encodeURIComponent(groupDid)}/config/public`;
325
+ try {
326
+ const res = await fetch(url);
327
+ if (!res.ok) {
328
+ console.warn(`[config] getForestScopeConfig: profile service returned ${res.status} for ${url}`);
329
+ return null;
330
+ }
331
+ const data = await res.json();
332
+ return { scopeFeeBps: typeof data?.scopeFeeBps === "number" ? data.scopeFeeBps : null };
333
+ } catch (err) {
334
+ console.warn(`[config] getForestScopeConfig: fetch failed for ${url} \u2014 ${err instanceof Error ? err.message : String(err)}`);
335
+ return null;
336
+ }
337
+ }
338
+
250
339
  // src/handles.ts
251
340
  var HANDLE_PATTERN = /^[a-z0-9._-]{3,30}$/;
252
341
  var HANDLE_EDGE = /^[.-]|[.-]$/;
253
342
  var HANDLE_CONSECUTIVE = /[.-]{2}/;
254
- var HANDLE_INPUT_PATTERN = "[a-z0-9._\\-]{3,30}";
343
+ var HANDLE_INPUT_PATTERN = String.raw`[a-z0-9._\-]{3,30}`;
255
344
  var HANDLE_ALLOWED_CHARS = /[^a-z0-9._-]/g;
256
345
  var RESERVED_HANDLES = /* @__PURE__ */ new Set([
257
346
  "admin",
@@ -396,6 +485,7 @@ var notifyPath = () => `/notify`;
396
485
  var notifySettingsPath = () => `/notify/settings`;
397
486
  export {
398
487
  ALLOWED_FRAME_ORIGINS,
488
+ APP_DISPLAY_NAME,
399
489
  HANDLE_ALLOWED_CHARS,
400
490
  HANDLE_ERROR,
401
491
  HANDLE_INPUT_PATTERN,
@@ -469,12 +559,15 @@ export {
469
559
  eventsCreatePath,
470
560
  eventsDashboardPath,
471
561
  getClientIP,
562
+ getForestScopeConfig,
563
+ getNodeSelf,
472
564
  getPort,
473
565
  getPublicUrl,
474
566
  getService,
475
567
  getServiceUrl,
476
568
  getSessionCookieName,
477
569
  getSessionCookieOptions,
570
+ hasRegistryServiceUrl,
478
571
  healthPath,
479
572
  isAllowedOrigin,
480
573
  isReservedHandle,
@@ -515,8 +608,10 @@ export {
515
608
  rateLimit,
516
609
  registryDocsPath,
517
610
  registryPath,
611
+ registryServiceUrl,
518
612
  servicesByTier,
519
613
  servicesByVisibility,
614
+ standaloneDashboardMiddleware,
520
615
  subscribePath,
521
616
  tier1Headers,
522
617
  tier2Headers,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ima-jin/config",
3
- "version": "0.8.0",
3
+ "version": "0.8.13",
4
4
  "description": "Shared configuration for Imajin services — CORS, service routing, session config, handle validation, and route helpers",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -24,11 +24,8 @@
24
24
  "src/",
25
25
  "next-headers.cjs"
26
26
  ],
27
- "dependencies": {
28
- "@ima-jin/tokens": "^0.8.0"
29
- },
30
27
  "peerDependencies": {
31
- "next": ">=14"
28
+ "next": ">=15.5.24"
32
29
  },
33
30
  "publishConfig": {
34
31
  "access": "public"
package/src/base-path.ts CHANGED
@@ -52,17 +52,17 @@ export function apiUrl(path: string): string {
52
52
  *
53
53
  * Absolute URLs (http://, https://) are passed through unchanged.
54
54
  *
55
- * TODO(WO2/correlation): For server-side service-to-service calls, callers should
56
- * forward the X-Correlation-Id header so the full request chain shares one ID.
57
- * apiFetch is browser-side too, so we can't read headers here automatically —
58
- * instead, pass it explicitly via init.headers when calling from a withLogger handler:
55
+ * For server-side service-to-service calls, forward the X-Correlation-Id header
56
+ * so the full request chain shares one ID. apiFetch is browser-side too, so it
57
+ * can't read headers here automatically — pass it explicitly via init.headers
58
+ * when calling from a withLogger handler:
59
59
  *
60
60
  * apiFetch('/api/something', {
61
61
  * headers: { 'x-correlation-id': correlationId },
62
62
  * });
63
63
  *
64
- * When WO3 instruments services, each withLogger handler receives correlationId in
65
- * ctx and should forward it on any outbound apiFetch/fetch calls.
64
+ * see: automatic correlationId forwarding on outbound apiFetch/fetch calls from
65
+ * withLogger handlers is not yet wired up.
66
66
  */
67
67
  export function apiFetch(path: string, init?: RequestInit): Promise<Response> {
68
68
  // Don't prefix absolute URLs
package/src/brand.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Human-facing product display name.
3
+ *
4
+ * This is the ONLY place the "(α)" (alpha) stage marker lives — flip it here
5
+ * (e.g. to "(β)" or drop it for GA) and every consumer (BuildInfo, nav bars,
6
+ * page titles/metadata, auth screens, email templates, etc.) picks it up.
7
+ *
8
+ * Do NOT use this for DIDs, URLs, package names, event names, localStorage
9
+ * keys, env vars, or any other code identifier — those stay as literal
10
+ * 'imajin' strings. This constant is display-text only.
11
+ */
12
+ export const APP_DISPLAY_NAME = "imajin (α)";
package/src/cors.ts CHANGED
@@ -25,7 +25,7 @@ export function corsHeaders(request: NextRequest): Record<string, string> {
25
25
  "Access-Control-Allow-Origin": allowed ? origin : "",
26
26
  "Access-Control-Allow-Credentials": "true",
27
27
  "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
28
- "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Caller-DID",
28
+ "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Caller-DID, X-App-DID, X-App-Authorization",
29
29
  "Vary": "Origin",
30
30
  };
31
31
  }
@@ -0,0 +1,56 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { buildPublicUrlAbsolute } from "./services";
3
+ import { corsHeaders, withCors } from "./cors";
4
+
5
+ export interface StandaloneDashboardMiddlewareOptions {
6
+ /** Service name used to build the hub tab URL (`/auth/<service>`). */
7
+ service: string;
8
+ /**
9
+ * Whether to also answer CORS preflight and stamp CORS headers on every
10
+ * other request — matches the app's pre-existing `/api/:path*` CORS
11
+ * middleware (#1525). Defaults to `true`; pass `false` for an app (e.g.
12
+ * dykil) that never had one.
13
+ */
14
+ cors?: boolean;
15
+ }
16
+
17
+ /**
18
+ * Shared standalone-dashboard-to-hub redirect body (#2332, carved out of
19
+ * #2275), used by every userspace service app's `middleware.ts` so the
20
+ * redirect + CORS pass-through logic isn't duplicated six times over.
21
+ *
22
+ * Each app still declares its own literal `export const config = { matcher:
23
+ * [...] }` — Next.js statically extracts `matcher` from the middleware
24
+ * file's AST at build time (see
25
+ * `next/dist/build/analysis/extract-const-value.js`: it only understands
26
+ * literal object/array/string/etc. expressions, not a function call or a
27
+ * destructured import) — so the matcher itself can't be factored out here
28
+ * without Next silently falling back to its default, unrestricted matcher.
29
+ * Only the function *body* is shared.
30
+ *
31
+ * The redirect is unconditional: a 308 to `/auth/<service>`, preserving the
32
+ * query string. The embedded rendering path `<ServiceEmbed>` loads
33
+ * (`?embed=hub&did=...`, see `apps/kernel/app/auth/lib/service-registry.ts`'s
34
+ * `buildEmbedSrc`) never reaches this function at all — it's excluded
35
+ * declaratively by each app's `matcher.missing` clause.
36
+ */
37
+ export function standaloneDashboardMiddleware(
38
+ request: NextRequest,
39
+ { service, cors = true }: StandaloneDashboardMiddlewareOptions,
40
+ ): NextResponse {
41
+ if (request.nextUrl.pathname === "/dashboard") {
42
+ const hubUrl = new URL(`${buildPublicUrlAbsolute("kernel")}/auth/${service}`);
43
+ hubUrl.search = request.nextUrl.search;
44
+ return NextResponse.redirect(hubUrl, 308);
45
+ }
46
+
47
+ if (!cors) {
48
+ return NextResponse.next();
49
+ }
50
+
51
+ if (request.method === "OPTIONS") {
52
+ return new NextResponse(null, { status: 204, headers: corsHeaders(request) });
53
+ }
54
+
55
+ return withCors(NextResponse.next(), request);
56
+ }
@@ -0,0 +1,51 @@
1
+ import { getServiceUrl } from './services';
2
+
3
+ /**
4
+ * Public forest-group (scope) config exposed by
5
+ * `GET /api/forest/{groupDid}/config/public` on the profile service. Only
6
+ * the field relevant to `.fair` fee-split calculation is modeled here — the
7
+ * rest of the config (enabledServices, landingService, etc.) is a
8
+ * community-settings concern, not a shared SDK concern.
9
+ */
10
+ export interface ForestScopeConfig {
11
+ /** Group's configured revenue-share fee, in basis points (0-500). Null if unset. */
12
+ scopeFeeBps: number | null;
13
+ }
14
+
15
+ function profileBaseUrl(): string {
16
+ // Like every other `*_SERVICE_URL`, this includes the service's path
17
+ // prefix (`/profile`) — callers append only the endpoint path
18
+ // (`/api/forest/...`). See #2046 (the equivalent REGISTRY_SERVICE_URL
19
+ // fix) for why omitting the prefix here is the wrong default.
20
+ if (process.env.PROFILE_SERVICE_URL) return process.env.PROFILE_SERVICE_URL;
21
+ const mode = process.env.NODE_ENV === 'production' ? 'prod' : 'dev';
22
+ const base = getServiceUrl('profile', mode) ?? 'http://localhost:3000';
23
+ return `${base}/profile`;
24
+ }
25
+
26
+ /**
27
+ * Fetch a forest group's public scope-fee config from the profile service
28
+ * (`GET /api/forest/{groupDid}/config/public`, documented in
29
+ * `apps/kernel/api-spec/profile.yaml`).
30
+ *
31
+ * Replaces raw `profile.forest_config` SQL reads that were duplicated
32
+ * across coffee, learn, and market (audit item 9 of #1983, #2001). Returns
33
+ * null on any failure — network error, non-2xx response — so callers can
34
+ * fall back to the same `scopeFeeBps: null` default they used when the raw
35
+ * SQL row was missing.
36
+ */
37
+ export async function getForestScopeConfig(groupDid: string): Promise<ForestScopeConfig | null> {
38
+ const url = `${profileBaseUrl()}/api/forest/${encodeURIComponent(groupDid)}/config/public`;
39
+ try {
40
+ const res = await fetch(url);
41
+ if (!res.ok) {
42
+ console.warn(`[config] getForestScopeConfig: profile service returned ${res.status} for ${url}`);
43
+ return null;
44
+ }
45
+ const data = (await res.json()) as { scopeFeeBps?: unknown };
46
+ return { scopeFeeBps: typeof data?.scopeFeeBps === 'number' ? data.scopeFeeBps : null };
47
+ } catch (err) {
48
+ console.warn(`[config] getForestScopeConfig: fetch failed for ${url} — ${err instanceof Error ? err.message : String(err)}`);
49
+ return null;
50
+ }
51
+ }
package/src/handles.ts CHANGED
@@ -16,7 +16,7 @@ const HANDLE_EDGE = /^[.-]|[.-]$/;
16
16
  const HANDLE_CONSECUTIVE = /[.-]{2}/;
17
17
 
18
18
  /** HTML input pattern attribute (no anchors, no flags) */
19
- export const HANDLE_INPUT_PATTERN = '[a-z0-9._\\-]{3,30}';
19
+ export const HANDLE_INPUT_PATTERN = String.raw`[a-z0-9._\-]{3,30}`;
20
20
 
21
21
  /** Characters allowed in handles — use in onChange filters */
22
22
  export const HANDLE_ALLOWED_CHARS = /[^a-z0-9._-]/g;