@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/dist/{index.mjs → index.cjs} +356 -19
- package/dist/index.d.cts +485 -0
- package/dist/index.d.ts +485 -0
- package/dist/index.js +195 -166
- package/next-headers.cjs +57 -0
- package/package.json +17 -10
- package/src/base-path.ts +10 -10
- package/src/brand.ts +12 -0
- package/src/cors.ts +8 -1
- package/src/dashboard-redirect-middleware.ts +56 -0
- package/src/forest-config.ts +51 -0
- package/src/handles.ts +3 -3
- package/src/index.ts +16 -1
- package/src/node-self.ts +45 -0
- package/src/rate-limit.ts +54 -0
- package/src/registry-service-url.ts +54 -0
- package/src/security-headers.ts +45 -0
- package/src/services.ts +65 -6
- package/src/session.ts +28 -1
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 = /[
|
|
16
|
+
const HANDLE_CONSECUTIVE = /[.-]{2}/;
|
|
17
17
|
|
|
18
18
|
/** HTML input pattern attribute (no anchors, no flags) */
|
|
19
|
-
export const HANDLE_INPUT_PATTERN =
|
|
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,
|
package/src/node-self.ts
ADDED
|
@@ -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 —
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
151
|
-
const d = domain ?? (typeof process
|
|
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
|
-
...(
|
|
73
|
+
...(omitDomain ? {} : { domain: ".imajin.ai" }),
|
|
47
74
|
maxAge: 60 * 60 * 24, // 24 hours
|
|
48
75
|
},
|
|
49
76
|
};
|