@ima-jin/config 0.7.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/src/cors.ts CHANGED
@@ -4,6 +4,13 @@ import { NextRequest, NextResponse } from "next/server";
4
4
  const ORIGIN_PATTERN = /^https:\/\/(dev-)?[a-z-]+\.imajin\.ai$/;
5
5
  const LOCAL_PATTERN = /^http:\/\/localhost:\d+$/;
6
6
 
7
+ /**
8
+ * CSP host-source list corresponding to ORIGIN_PATTERN (prod).
9
+ * Used by security-headers.ts to build frame-ancestors directives.
10
+ * If the allowed-origin set changes, update ORIGIN_PATTERN and this constant together.
11
+ */
12
+ export const ALLOWED_FRAME_ORIGINS = 'https://*.imajin.ai https://imajin.ai';
13
+
7
14
  export function isAllowedOrigin(origin: string | null): boolean {
8
15
  if (!origin) return false;
9
16
  if (ORIGIN_PATTERN.test(origin)) return true;
@@ -18,7 +25,7 @@ export function corsHeaders(request: NextRequest): Record<string, string> {
18
25
  "Access-Control-Allow-Origin": allowed ? origin : "",
19
26
  "Access-Control-Allow-Credentials": "true",
20
27
  "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
21
- "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",
22
29
  "Vary": "Origin",
23
30
  };
24
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
@@ -10,13 +10,13 @@
10
10
  export const HANDLE_PATTERN = /^[a-z0-9._-]{3,30}$/;
11
11
 
12
12
  /** Regex: disallowed leading/trailing characters */
13
- const HANDLE_EDGE = /^[.\-]|[.\-]$/;
13
+ const HANDLE_EDGE = /^[.-]|[.-]$/;
14
14
 
15
15
  /** Regex: consecutive dots or hyphens */
16
- const HANDLE_CONSECUTIVE = /[.\-]{2}/;
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;
package/src/index.ts CHANGED
@@ -1,4 +1,8 @@
1
- export { isAllowedOrigin, corsHeaders, corsOptions, withCors, validateOrigin } from "./cors";
1
+ export { isAllowedOrigin, corsHeaders, corsOptions, withCors, validateOrigin, ALLOWED_FRAME_ORIGINS } from "./cors";
2
+ export { standaloneDashboardMiddleware } from "./dashboard-redirect-middleware";
3
+ export type { StandaloneDashboardMiddlewareOptions } from "./dashboard-redirect-middleware";
4
+ export { tier1Headers, tier2Headers, tier3Headers } from './security-headers';
5
+ export { APP_DISPLAY_NAME } from './brand';
2
6
 
3
7
  export {
4
8
  SERVICES,
@@ -8,6 +12,7 @@ export {
8
12
  getServiceUrl,
9
13
  getPublicUrl,
10
14
  buildPublicUrl,
15
+ buildPublicUrlAbsolute,
11
16
  servicesByTier,
12
17
  servicesByVisibility,
13
18
  buildServiceUrlMap,
@@ -27,6 +32,16 @@ export {
27
32
 
28
33
  export { apiFetch, apiUrl } from "./base-path";
29
34
 
35
+ export { rateLimit, getClientIP } from "./rate-limit";
36
+
37
+ export { getNodeSelf } from "./node-self";
38
+ export type { NodeSelfInfo } from "./node-self";
39
+
40
+ export { registryServiceUrl, hasRegistryServiceUrl } from "./registry-service-url";
41
+
42
+ export { getForestScopeConfig } from "./forest-config";
43
+ export type { ForestScopeConfig } from "./forest-config";
44
+
30
45
  export {
31
46
  HANDLE_PATTERN,
32
47
  HANDLE_INPUT_PATTERN,
@@ -0,0 +1,45 @@
1
+ import { registryServiceUrl } from './registry-service-url';
2
+
3
+ /**
4
+ * Minimal public node metadata returned by GET /registry/api/node/self.
5
+ * Mirrors the subset of `relay.relay_config` the registry service exposes
6
+ * publicly — never the private config (profile artifact JWS, etc).
7
+ */
8
+ export interface NodeSelfInfo {
9
+ /** This node's own DID (did:imajin:...). */
10
+ did: string;
11
+ /** DID of the human/entity operating this node, if configured. */
12
+ nodeOperatorDid: string | null;
13
+ /** Node fee, in basis points, applied to .fair settlements on this node. */
14
+ nodeFeeBps: number | null;
15
+ /** Buyer credit, in basis points, applied to .fair settlements on this node. */
16
+ buyerCreditBps: number | null;
17
+ }
18
+
19
+ /**
20
+ * Fetch this node's public identity + .fair fee config from the registry
21
+ * service (`GET /registry/api/node/self`, #2000).
22
+ *
23
+ * Replaces raw `relay.relay_config` SQL reads that were duplicated across
24
+ * coffee, learn, events, and market (audit item 8 of #1983). Returns null on
25
+ * any failure — network error, non-2xx response (e.g. 503 when the node
26
+ * identity hasn't been bootstrapped yet) — so callers can fall back to
27
+ * defaults exactly as they did when the raw SQL row was missing. A non-2xx
28
+ * response is logged with the URL actually hit (no secrets) so a
29
+ * misconfigured REGISTRY_SERVICE_URL prefix is visible instead of failing
30
+ * silently (#2046).
31
+ */
32
+ export async function getNodeSelf(): Promise<NodeSelfInfo | null> {
33
+ const url = `${registryServiceUrl()}/api/node/self`;
34
+ try {
35
+ const res = await fetch(url);
36
+ if (!res.ok) {
37
+ console.warn(`[config] getNodeSelf: registry returned ${res.status} for ${url} — check REGISTRY_SERVICE_URL`);
38
+ return null;
39
+ }
40
+ return (await res.json()) as NodeSelfInfo;
41
+ } catch (err) {
42
+ console.warn(`[config] getNodeSelf: fetch failed for ${url} — ${err instanceof Error ? err.message : String(err)}`);
43
+ return null;
44
+ }
45
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Simple sliding-window in-memory rate limiter.
3
+ * Safe for single-process Next.js. Per-worker in multi-worker setups (acceptable).
4
+ */
5
+
6
+ interface RateLimitEntry {
7
+ timestamps: number[];
8
+ lastSeen: number;
9
+ }
10
+
11
+ const store = new Map<string, RateLimitEntry>();
12
+ let lastGlobalCleanup = Date.now();
13
+
14
+ function pruneStore(now: number) {
15
+ if (now - lastGlobalCleanup < 60_000) return;
16
+ store.forEach((entry, key) => {
17
+ if (now - entry.lastSeen > 120_000) {
18
+ store.delete(key);
19
+ }
20
+ });
21
+ lastGlobalCleanup = now;
22
+ }
23
+
24
+ export function rateLimit(
25
+ key: string,
26
+ limit: number,
27
+ windowMs: number
28
+ ): { limited: boolean; retryAfter: number } {
29
+ const now = Date.now();
30
+ pruneStore(now);
31
+
32
+ const windowStart = now - windowMs;
33
+ const entry = store.get(key) ?? { timestamps: [], lastSeen: now };
34
+
35
+ entry.timestamps = entry.timestamps.filter((t) => t > windowStart);
36
+ entry.lastSeen = now;
37
+
38
+ if (entry.timestamps.length >= limit) {
39
+ const oldest = entry.timestamps[0];
40
+ const retryAfter = Math.ceil((oldest + windowMs - now) / 1000);
41
+ store.set(key, entry);
42
+ return { limited: true, retryAfter: Math.max(1, retryAfter) };
43
+ }
44
+
45
+ entry.timestamps.push(now);
46
+ store.set(key, entry);
47
+ return { limited: false, retryAfter: 0 };
48
+ }
49
+
50
+ export function getClientIP(request: Request): string {
51
+ const forwarded = request.headers.get('x-forwarded-for');
52
+ if (forwarded) return forwarded.split(',')[0].trim();
53
+ return '127.0.0.1';
54
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Single resolver for the registry service's base URL (#2061 — kernel was
3
+ * the last `REGISTRY_URL` holdout after #2048 established the
4
+ * `*_SERVICE_URL` convention).
5
+ *
6
+ * The returned value always includes the `/registry` path prefix, like
7
+ * every other `*_SERVICE_URL` (#2046) — callers append only the endpoint
8
+ * path (e.g. `/api/node/self`, `/relay/operations`).
9
+ *
10
+ * Precedence:
11
+ * 1. `REGISTRY_SERVICE_URL` — the current convention.
12
+ * 2. `REGISTRY_URL` — deprecated kernel-only legacy name. Used if set,
13
+ * but logs a one-time deprecation warning per process.
14
+ * 3. `http://localhost:${PORT || 3000}/registry` — the mode-aware
15
+ * fallback (PORT is 3000 in dev, 7000 in prod) that
16
+ * `relay-well-known.ts` already used before this consolidation.
17
+ */
18
+
19
+ let warnedAboutLegacyRegistryUrl = false;
20
+
21
+ export function registryServiceUrl(): string {
22
+ if (process.env.REGISTRY_SERVICE_URL) return process.env.REGISTRY_SERVICE_URL;
23
+
24
+ if (process.env.REGISTRY_URL) {
25
+ if (!warnedAboutLegacyRegistryUrl) {
26
+ console.warn('REGISTRY_URL is deprecated — set REGISTRY_SERVICE_URL');
27
+ warnedAboutLegacyRegistryUrl = true;
28
+ }
29
+ return process.env.REGISTRY_URL;
30
+ }
31
+
32
+ return `http://localhost:${process.env.PORT || 3000}/registry`;
33
+ }
34
+
35
+ /**
36
+ * True when the registry URL is explicitly configured via either the
37
+ * current (`REGISTRY_SERVICE_URL`) or deprecated (`REGISTRY_URL`) env var.
38
+ *
39
+ * `registryServiceUrl()` always returns a value (it falls back to a
40
+ * localhost guess), so callers that previously guarded on
41
+ * `!process.env.REGISTRY_URL` to skip/no-op when the registry isn't
42
+ * configured should check this instead.
43
+ */
44
+ export function hasRegistryServiceUrl(): boolean {
45
+ return Boolean(process.env.REGISTRY_SERVICE_URL || process.env.REGISTRY_URL);
46
+ }
47
+
48
+ /**
49
+ * Test-only: resets the one-time deprecation warning latch so each test
50
+ * can assert the warning fires exactly once for its own scenario.
51
+ */
52
+ export function __resetRegistryServiceUrlWarningForTests(): void {
53
+ warnedAboutLegacyRegistryUrl = false;
54
+ }
@@ -0,0 +1,45 @@
1
+ import { ALLOWED_FRAME_ORIGINS } from './cors';
2
+
3
+ type HeaderEntry = { key: string; value: string };
4
+
5
+ /**
6
+ * Build the frame-ancestors directive value.
7
+ * Mirrors isAllowedOrigin(): includes localhost:* in non-prod so local
8
+ * hub-iframe development works without disabling frame protection.
9
+ */
10
+ function buildFrameAncestors(): string {
11
+ const local = process.env.NODE_ENV !== 'production' ? ' http://localhost:*' : '';
12
+ return `frame-ancestors 'self' ${ALLOWED_FRAME_ORIGINS}${local}`;
13
+ }
14
+
15
+ /**
16
+ * Tier 1 — hard deny framing.
17
+ * Use on credential and auth-sensitive pages that must never be embedded.
18
+ */
19
+ export function tier1Headers(): HeaderEntry[] {
20
+ return [
21
+ { key: 'Content-Security-Policy', value: "frame-ancestors 'none'" },
22
+ { key: 'X-Frame-Options', value: 'DENY' },
23
+ ];
24
+ }
25
+
26
+ /**
27
+ * Tier 2 — allow framing by Imajin origins only.
28
+ * Use on routes embedded by the auth hub (service dashboards, document viewer, surveys).
29
+ * Does not include X-Frame-Options — it cannot express an allow-list, and SAMEORIGIN
30
+ * would break cross-subdomain framing. CSP frame-ancestors does the work here.
31
+ */
32
+ export function tier2Headers(): HeaderEntry[] {
33
+ return [{ key: 'Content-Security-Policy', value: buildFrameAncestors() }];
34
+ }
35
+
36
+ /**
37
+ * Tier 3 — baseline hardening.
38
+ * Safe to apply on every route in every app.
39
+ */
40
+ export function tier3Headers(): HeaderEntry[] {
41
+ return [
42
+ { key: 'X-Content-Type-Options', value: 'nosniff' },
43
+ { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
44
+ ];
45
+ }
package/src/services.ts CHANGED
@@ -1,7 +1,17 @@
1
1
  /**
2
- * Canonical service manifest — single source of truth for all Imajin services.
2
+ * Canonical service manifest — infra/build-time topology for all Imajin
3
+ * services (dev/prod ports, Postgres schema, deployment tier, Caddy/pm2
4
+ * routing, docs sync, `/registry/api/specs`). These consumers run without a
5
+ * live identity/DB context (build scripts, edge config, port-based
6
+ * routing), so this stays a static, synchronously-importable array.
3
7
  *
4
- * Consumers: registry specs, Caddy config, pm2 ecosystem, docs sync, shared nav.
8
+ * NOT the nav authority (#2425): which apps an authenticated identity sees
9
+ * in the kernel hub (auth submenu, launcher, home tiles) is now derived
10
+ * from `registry.apps` (#1990) via `apps/kernel/src/lib/kernel/app-nav.ts`,
11
+ * not from this file — a disabled/pruned app disappears from nav by
12
+ * flipping registry state, independent of whether it still has a row here
13
+ * for port/Caddy purposes. Consumers: registry specs, Caddy config, pm2
14
+ * ecosystem, docs sync.
5
15
  */
6
16
 
7
17
  export type ServiceTier = "core" | "imajin";
@@ -46,6 +56,11 @@ export const SERVICES: readonly ServiceDefinition[] = [
46
56
  { name: "media", label: "Media", icon: "📁", description: "Files, images, and .fair attribution", devPort: 3000, prodPort: 7000, schema: "media", tier: "core", visibility: "authenticated", category: "kernel" },
47
57
  { name: "registry", label: "Registry", icon: "📡", description: "Service registry and DFOS relay", devPort: 3000, prodPort: 7000, schema: "registry", tier: "core", visibility: "public", category: "kernel" },
48
58
  { name: "notify", label: "Notify", icon: "🔔", description: "Notifications and preferences", devPort: 3000, prodPort: 7000, schema: "notify", tier: "core", visibility: "internal", category: "kernel" },
59
+ { name: "usage", label: "Usage", icon: "📊", description: "Inference cost ledger — incurred, billed, drift, signed rollup", devPort: 3000, prodPort: 7000, schema: "usage", tier: "core", visibility: "authenticated", category: "kernel" },
60
+ { name: "calendar", label: "Calendar", icon: "📅", description: "Temporal entries — availability, meetings, events, and reminders", devPort: 3000, prodPort: 7000, schema: null, tier: "core", visibility: "authenticated", category: "kernel" },
61
+ { name: "jin", label: "Jin", icon: "🧭", description: "Operator dashboard — signed approvals, proposals, and usage feed", devPort: 3000, prodPort: 7000, schema: "operator", tier: "core", visibility: "internal", category: "kernel" },
62
+ { name: "google", label: "Google Workspace", icon: "📨", description: "Gmail/Calendar/Drive/Meet connector — OAuth2 auth-code, sealed refresh token", devPort: 3000, prodPort: 7000, schema: null, tier: "core", visibility: "authenticated", category: "kernel" },
63
+ { name: "vault", label: "Vault", icon: "🔐", description: "Sealed secrets, delegation grants, and vault-minted service credentials", devPort: 3000, prodPort: 7000, schema: null, tier: "core", visibility: "internal", category: "kernel" },
49
64
 
50
65
  // Core apps — separate processes
51
66
  { name: "events", label: "Events", icon: "🎫", description: "Event creation, ticketing, and management", devPort: 3006, prodPort: 7006, schema: "events", tier: "core", visibility: "public", category: "core" },
@@ -62,6 +77,10 @@ export const SERVICES: readonly ServiceDefinition[] = [
62
77
  { name: 'github', label: 'GitHub', icon: '🐙', description: 'Source code', devPort: 3000, prodPort: 7000, schema: "public", tier: 'core', visibility: 'public', category: 'meta', externalUrl: 'https://github.com/ima-jin/imajin-ai' },
63
78
  { name: 'docs', label: 'Docs', icon: '📄', description: 'API documentation', devPort: 3000, prodPort: 7000, schema: "public", tier: 'core', visibility: 'public', category: 'meta', wwwPath: '/developer-guide' },
64
79
 
80
+ // Daemon processes — not web apps, no HTTP port exposed
81
+ { name: 'broker-agent', label: 'Broker Agent', icon: '🤖', description: 'Telegram broker agent — conversational surface for social coordination (#1101)', devPort: 0, prodPort: 0, schema: null, tier: 'core', visibility: 'internal', category: 'infrastructure' },
82
+ { name: 'corpus', label: 'Corpus', icon: '🔎', description: 'Per-DID corpus indexing and BM25 search service (#1737)', devPort: 8013, prodPort: 8013, schema: null, tier: 'core', visibility: 'internal', category: 'infrastructure' },
83
+
65
84
  // Connected apps (separate repos) will use the plugin architecture (#249).
66
85
  // Not included here — they authenticate via delegated sessions, not the monorepo manifest.
67
86
  ] as const satisfies readonly ServiceDefinition[];
@@ -76,7 +95,8 @@ export function getService(name: string): ServiceDefinition | undefined {
76
95
  /** Get the port for a service in a given environment */
77
96
  export function getPort(name: string, env: "dev" | "prod" = "dev"): number | undefined {
78
97
  const svc = getService(name);
79
- return svc ? (env === "prod" ? svc.prodPort : svc.devPort) : undefined;
98
+ if (!svc) return undefined;
99
+ return env === "prod" ? svc.prodPort : svc.devPort;
80
100
  }
81
101
 
82
102
  /** Get internal URL for a service (server-side calls) */
@@ -115,7 +135,7 @@ export function getPublicUrl(
115
135
 
116
136
  // Single-domain mode: prefix contains dots (e.g. "dev-jin.imajin.ai/")
117
137
  // Build https://{prefix}{name} instead of https://{prefix}-{name}.{domain}
118
- if (prefix && prefix.includes(".")) {
138
+ if (prefix?.includes(".")) {
119
139
  const base = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
120
140
  // "www" or "kernel" = root of the node, no suffix
121
141
  if (name === "www" || name === "kernel") return `https://${base}`;
@@ -147,8 +167,8 @@ export function buildPublicUrl(
147
167
  if (explicit) return explicit;
148
168
  }
149
169
 
150
- const p = servicePrefix ?? (typeof process !== "undefined" ? process.env.NEXT_PUBLIC_SERVICE_PREFIX : undefined) ?? "https://";
151
- const d = domain ?? (typeof process !== "undefined" ? process.env.NEXT_PUBLIC_DOMAIN : undefined) ?? "imajin.ai";
170
+ const p = servicePrefix ?? (typeof process === "undefined" ? undefined : process.env.NEXT_PUBLIC_SERVICE_PREFIX) ?? "https://";
171
+ const d = domain ?? (typeof process === "undefined" ? undefined : process.env.NEXT_PUBLIC_DOMAIN) ?? "imajin.ai";
152
172
 
153
173
  // Localhost detection
154
174
  if (p.includes("localhost") || d.includes("localhost")) {
@@ -168,6 +188,45 @@ export function buildPublicUrl(
168
188
  return getPublicUrl(name, { prefix: match, domain: d });
169
189
  }
170
190
 
191
+ /**
192
+ * Build an ABSOLUTE public URL for a service — for use OUTSIDE the browser
193
+ * origin: email links, Stripe/webhook return/success URLs, externally shared
194
+ * links, and cross-node links. Unlike buildPublicUrl(), this never returns a
195
+ * relative path.
196
+ *
197
+ * Resolution order:
198
+ * 1. explicit NEXT_PUBLIC_{NAME}_URL env var (only if already absolute)
199
+ * 2. localhost dev → http://localhost:{devPort} from the canonical port map
200
+ * 3. otherwise an absolute https URL derived from NEXT_PUBLIC_SERVICE_PREFIX
201
+ * + NEXT_PUBLIC_DOMAIN via getPublicUrl()
202
+ *
203
+ * @example
204
+ * buildPublicUrlAbsolute('chat') → https://jin.imajin.ai/chat (single-domain)
205
+ * buildPublicUrlAbsolute('kernel') → https://jin.imajin.ai (node root)
206
+ */
207
+ export function buildPublicUrlAbsolute(name: string): string {
208
+ // 1. Explicit per-service override wins if it is already absolute.
209
+ if (typeof process !== "undefined") {
210
+ const explicit = process.env[`NEXT_PUBLIC_${name.toUpperCase()}_URL`];
211
+ if (explicit && /^https?:\/\//.test(explicit)) return explicit;
212
+ }
213
+
214
+ const prefix =
215
+ (typeof process === "undefined" ? undefined : process.env.NEXT_PUBLIC_SERVICE_PREFIX) ?? "https://";
216
+ const domain =
217
+ (typeof process === "undefined" ? undefined : process.env.NEXT_PUBLIC_DOMAIN) ?? "imajin.ai";
218
+
219
+ // 2. Localhost dev → canonical dev port.
220
+ if (prefix.includes("localhost") || domain.includes("localhost")) {
221
+ const port = getPort(name, "dev");
222
+ return port ? `http://localhost:${port}` : "http://localhost:3000";
223
+ }
224
+
225
+ // 3. Force absolute construction (getPublicUrl never returns a relative path).
226
+ const match = prefix.replace(/^https?:\/\//, "").replace(/-$/, "") || undefined;
227
+ return getPublicUrl(name, { prefix: match, domain });
228
+ }
229
+
171
230
  /** All service names */
172
231
  export const SERVICE_NAMES = SERVICES.map((s) => s.name);
173
232
 
package/src/session.ts CHANGED
@@ -27,15 +27,42 @@ function isLocalhost(): boolean {
27
27
  return prefix.includes("localhost");
28
28
  }
29
29
 
30
+ /**
31
+ * Cookie domain scope (#1069 Phase 1).
32
+ *
33
+ * `SESSION_COOKIE_SCOPE` controls whether the deployed session cookie is a
34
+ * shared parent-domain cookie or a host-only cookie:
35
+ * - unset / "domain" (default): current behavior — `domain: ".imajin.ai"`,
36
+ * readable by every `*.imajin.ai` subdomain. Nothing changes unless this
37
+ * is explicitly set.
38
+ * - "host": omit the `domain` attribute entirely, so the cookie is scoped
39
+ * to the exact host that set it and is NOT sent to other subdomains.
40
+ * Apps that relied on reading the shared cookie must adopt scoped app
41
+ * tokens first (see `requireSessionOrAppToken` in `@ima-jin/auth` and
42
+ * `docs/security/cookie-isolation.md`) before this is flipped for real.
43
+ *
44
+ * This flag is a primitive for the staged rollout described in
45
+ * `docs/security/cookie-isolation.md` — flipping it is a separate, later
46
+ * phase, not part of shipping the primitive itself.
47
+ */
48
+ function isHostScoped(): boolean {
49
+ if (typeof process === "undefined") return false;
50
+ return process.env.SESSION_COOKIE_SCOPE === "host";
51
+ }
52
+
30
53
  /** Cookie options for cross-subdomain sessions.
31
54
  * When called with no argument, auto-detects from IMAJIN_ENV (same logic as getSessionCookieName).
32
55
  * Accepts optional "dev" | "prod" override for explicit control.
33
56
  *
34
57
  * Localhost-aware: when SERVICE_PREFIX contains "localhost", uses
35
58
  * settings compatible with HTTP on localhost (no domain, not secure, lax sameSite).
59
+ *
60
+ * See {@link isHostScoped} for the `SESSION_COOKIE_SCOPE=host` opt-in that
61
+ * narrows the deployed cookie to host-only instead of `.imajin.ai`.
36
62
  */
37
63
  export function getSessionCookieOptions(env?: "dev" | "prod") {
38
64
  const local = isLocalhost();
65
+ const omitDomain = local || isHostScoped();
39
66
  return {
40
67
  name: getSessionCookieName(env),
41
68
  options: {
@@ -43,7 +70,7 @@ export function getSessionCookieOptions(env?: "dev" | "prod") {
43
70
  secure: !local,
44
71
  sameSite: local ? ("lax" as const) : ("none" as const),
45
72
  path: "/",
46
- ...(local ? {} : { domain: ".imajin.ai" }),
73
+ ...(omitDomain ? {} : { domain: ".imajin.ai" }),
47
74
  maxAge: 60 * 60 * 24, // 24 hours
48
75
  },
49
76
  };