@ima-jin/config 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,153 +1,8 @@
1
- var __defProp = Object.defineProperty;
2
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
- var __getOwnPropNames = Object.getOwnPropertyNames;
4
- var __hasOwnProp = Object.prototype.hasOwnProperty;
5
- var __export = (target, all) => {
6
- for (var name in all)
7
- __defProp(target, name, { get: all[name], enumerable: true });
8
- };
9
- var __copyProps = (to, from, except, desc) => {
10
- if (from && typeof from === "object" || typeof from === "function") {
11
- for (let key of __getOwnPropNames(from))
12
- if (!__hasOwnProp.call(to, key) && key !== except)
13
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
- }
15
- return to;
16
- };
17
- var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
18
-
19
- // src/index.ts
20
- var index_exports = {};
21
- __export(index_exports, {
22
- HANDLE_ALLOWED_CHARS: () => HANDLE_ALLOWED_CHARS,
23
- HANDLE_ERROR: () => HANDLE_ERROR,
24
- HANDLE_INPUT_PATTERN: () => HANDLE_INPUT_PATTERN,
25
- HANDLE_PATTERN: () => HANDLE_PATTERN,
26
- RESERVED_HANDLES: () => RESERVED_HANDLES,
27
- SERVICES: () => SERVICES,
28
- SERVICE_NAMES: () => SERVICE_NAMES,
29
- SESSION_COOKIE_NAME: () => SESSION_COOKIE_NAME,
30
- apiFetch: () => apiFetch,
31
- apiUrl: () => apiUrl,
32
- articleAuthorPath: () => articleAuthorPath,
33
- articlePath: () => articlePath,
34
- articlesPath: () => articlesPath,
35
- authAgentsPath: () => authAgentsPath,
36
- authAppsPath: () => authAppsPath,
37
- authAttestationsPath: () => authAttestationsPath,
38
- authAuthorizePath: () => authAuthorizePath,
39
- authDeveloperAppPath: () => authDeveloperAppPath,
40
- authDeveloperAppsPath: () => authDeveloperAppsPath,
41
- authGroupSettingsPath: () => authGroupSettingsPath,
42
- authGroupsPath: () => authGroupsPath,
43
- authLoginPath: () => authLoginPath,
44
- authMembersPath: () => authMembersPath,
45
- authNewGroupPath: () => authNewGroupPath,
46
- authNewStubPath: () => authNewStubPath,
47
- authNotificationsPath: () => authNotificationsPath,
48
- authOnboardPath: () => authOnboardPath,
49
- authRegisterPath: () => authRegisterPath,
50
- authSecurityPath: () => authSecurityPath,
51
- authSettingsPath: () => authSettingsPath,
52
- authStubsPath: () => authStubsPath,
53
- bugsAdminPath: () => bugsAdminPath,
54
- bugsPath: () => bugsPath,
55
- buildPath: () => buildPath,
56
- buildPublicUrl: () => buildPublicUrl,
57
- buildServiceUrlMap: () => buildServiceUrlMap,
58
- bumpPath: () => bumpPath,
59
- chatConversationPath: () => chatConversationPath,
60
- chatConversationsPath: () => chatConversationsPath,
61
- chatPath: () => chatPath,
62
- coffeeDashboardPath: () => coffeeDashboardPath,
63
- coffeeEditPath: () => coffeeEditPath,
64
- coffeeHandlePath: () => coffeeHandlePath,
65
- coffeeSuccessPath: () => coffeeSuccessPath,
66
- connectionsInvitePath: () => connectionsInvitePath,
67
- connectionsPath: () => connectionsPath,
68
- connectionsPodPath: () => connectionsPodPath,
69
- corsHeaders: () => corsHeaders,
70
- corsOptions: () => corsOptions,
71
- developerGuidePath: () => developerGuidePath,
72
- docsPath: () => docsPath,
73
- dykilCreatePath: () => dykilCreatePath,
74
- dykilDashboardPath: () => dykilDashboardPath,
75
- dykilEmbedPath: () => dykilEmbedPath,
76
- dykilHandlePath: () => dykilHandlePath,
77
- dykilResultsPath: () => dykilResultsPath,
78
- dykilSurveyPath: () => dykilSurveyPath,
79
- eventAdminPath: () => eventAdminPath,
80
- eventCheckoutSuccessPath: () => eventCheckoutSuccessPath,
81
- eventEditPath: () => eventEditPath,
82
- eventEditUrl: () => eventEditUrl,
83
- eventMyTicketsPath: () => eventMyTicketsPath,
84
- eventMyTicketsUrl: () => eventMyTicketsUrl,
85
- eventPath: () => eventPath,
86
- eventRegisterPath: () => eventRegisterPath,
87
- eventRegisterUrl: () => eventRegisterUrl,
88
- eventUrl: () => eventUrl,
89
- eventsAdminListPath: () => eventsAdminListPath,
90
- eventsCheckoutSuccessPath: () => eventsCheckoutSuccessPath,
91
- eventsCreatePath: () => eventsCreatePath,
92
- eventsDashboardPath: () => eventsDashboardPath,
93
- getPort: () => getPort,
94
- getPublicUrl: () => getPublicUrl,
95
- getService: () => getService,
96
- getServiceUrl: () => getServiceUrl,
97
- getSessionCookieName: () => getSessionCookieName,
98
- getSessionCookieOptions: () => getSessionCookieOptions,
99
- healthPath: () => healthPath,
100
- isAllowedOrigin: () => isAllowedOrigin,
101
- isReservedHandle: () => isReservedHandle,
102
- isValidHandle: () => isValidHandle,
103
- learnCourseDashboardPath: () => learnCourseDashboardPath,
104
- learnCourseLessonPath: () => learnCourseLessonPath,
105
- learnCoursePath: () => learnCoursePath,
106
- learnCoursePresentPath: () => learnCoursePresentPath,
107
- learnCourseStudentsPath: () => learnCourseStudentsPath,
108
- learnDashboardPath: () => learnDashboardPath,
109
- learnHandlePath: () => learnHandlePath,
110
- linksDashboardPath: () => linksDashboardPath,
111
- linksEditPath: () => linksEditPath,
112
- linksHandlePath: () => linksHandlePath,
113
- marketCheckoutSuccessPath: () => marketCheckoutSuccessPath,
114
- marketDashboardPath: () => marketDashboardPath,
115
- marketListingEditPath: () => marketListingEditPath,
116
- marketListingPath: () => marketListingPath,
117
- marketNewListingPath: () => marketNewListingPath,
118
- marketSellerPath: () => marketSellerPath,
119
- marketSettingsPath: () => marketSettingsPath,
120
- mediaPath: () => mediaPath,
121
- normalizeHandleInput: () => normalizeHandleInput,
122
- notifyPath: () => notifyPath,
123
- notifySettingsPath: () => notifySettingsPath,
124
- payHistoryPath: () => payHistoryPath,
125
- payPath: () => payPath,
126
- payPayoutsPath: () => payPayoutsPath,
127
- payTopupPath: () => payTopupPath,
128
- payTopupSuccessPath: () => payTopupSuccessPath,
129
- privacyPath: () => privacyPath,
130
- profileEditPath: () => profileEditPath,
131
- profileLoginPath: () => profileLoginPath,
132
- profilePath: () => profilePath,
133
- profileRegisterPath: () => profileRegisterPath,
134
- profileUrl: () => profileUrl,
135
- projectPath: () => projectPath,
136
- registryDocsPath: () => registryDocsPath,
137
- registryPath: () => registryPath,
138
- servicesByTier: () => servicesByTier,
139
- servicesByVisibility: () => servicesByVisibility,
140
- subscribePath: () => subscribePath,
141
- validateOrigin: () => validateOrigin,
142
- whitepaperPath: () => whitepaperPath,
143
- withCors: () => withCors
144
- });
145
- module.exports = __toCommonJS(index_exports);
146
-
147
1
  // src/cors.ts
148
- var import_server = require("next/server");
2
+ import { NextResponse } from "next/server";
149
3
  var ORIGIN_PATTERN = /^https:\/\/(dev-)?[a-z-]+\.imajin\.ai$/;
150
4
  var LOCAL_PATTERN = /^http:\/\/localhost:\d+$/;
5
+ var ALLOWED_FRAME_ORIGINS = "https://*.imajin.ai https://imajin.ai";
151
6
  function isAllowedOrigin(origin) {
152
7
  if (!origin) return false;
153
8
  if (ORIGIN_PATTERN.test(origin)) return true;
@@ -166,7 +21,7 @@ function corsHeaders(request) {
166
21
  };
167
22
  }
168
23
  function corsOptions(request) {
169
- return new import_server.NextResponse(null, { status: 204, headers: corsHeaders(request) });
24
+ return new NextResponse(null, { status: 204, headers: corsHeaders(request) });
170
25
  }
171
26
  function withCors(response, request) {
172
27
  const headers = corsHeaders(request);
@@ -181,6 +36,27 @@ function validateOrigin(request) {
181
36
  return isAllowedOrigin(origin);
182
37
  }
183
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
+ }
59
+
184
60
  // src/services.ts
185
61
  var SERVICES = [
186
62
  // Kernel services — individually visible, all run on the kernel process (port 3000/7000)
@@ -204,7 +80,9 @@ var SERVICES = [
204
80
  // Meta — project info and external resources surfaced in the launcher
205
81
  { name: "project", label: "Project", icon: "\u{1F4D6}", description: "Thesis, whitepaper, essays, RFCs", devPort: 3e3, prodPort: 7e3, schema: "public", tier: "core", visibility: "public", category: "meta", wwwPath: "/project" },
206
82
  { 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" },
207
- { 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" }
83
+ { 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
+ // 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" }
208
86
  // Connected apps (separate repos) will use the plugin architecture (#249).
209
87
  // Not included here — they authenticate via delegated sessions, not the monorepo manifest.
210
88
  ];
@@ -213,7 +91,8 @@ function getService(name) {
213
91
  }
214
92
  function getPort(name, env = "dev") {
215
93
  const svc = getService(name);
216
- return svc ? env === "prod" ? svc.prodPort : svc.devPort : void 0;
94
+ if (!svc) return void 0;
95
+ return env === "prod" ? svc.prodPort : svc.devPort;
217
96
  }
218
97
  function getServiceUrl(name, env = "dev") {
219
98
  const port = getPort(name, env);
@@ -221,18 +100,18 @@ function getServiceUrl(name, env = "dev") {
221
100
  }
222
101
  function getPublicUrl(name, options) {
223
102
  const svc = SERVICES.find((s) => s.name === name);
224
- if (svc == null ? void 0 : svc.externalUrl) return svc.externalUrl;
225
- const domain = (options == null ? void 0 : options.domain) || "imajin.ai";
226
- const prefix = options == null ? void 0 : options.prefix;
227
- if (svc == null ? void 0 : svc.wwwPath) {
103
+ if (svc?.externalUrl) return svc.externalUrl;
104
+ const domain = options?.domain || "imajin.ai";
105
+ const prefix = options?.prefix;
106
+ if (svc?.wwwPath) {
228
107
  const wwwUrl = getPublicUrl("www", options);
229
108
  return `${wwwUrl}${svc.wwwPath}`;
230
109
  }
231
- if (domain.includes("localhost") || (prefix == null ? void 0 : prefix.includes("localhost"))) {
110
+ if (domain.includes("localhost") || prefix?.includes("localhost")) {
232
111
  const port = getPort(name, "dev");
233
112
  return port ? `http://localhost:${port}` : `http://localhost:3000`;
234
113
  }
235
- if (prefix && prefix.includes(".")) {
114
+ if (prefix?.includes(".")) {
236
115
  const base = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
237
116
  if (name === "www" || name === "kernel") return `https://${base}`;
238
117
  return `https://${base}/${name}`;
@@ -246,8 +125,8 @@ function buildPublicUrl(name, servicePrefix, domain) {
246
125
  const explicit = process.env[envKey];
247
126
  if (explicit) return explicit;
248
127
  }
249
- const p = servicePrefix ?? (typeof process !== "undefined" ? process.env.NEXT_PUBLIC_SERVICE_PREFIX : void 0) ?? "https://";
250
- const d = domain ?? (typeof process !== "undefined" ? process.env.NEXT_PUBLIC_DOMAIN : void 0) ?? "imajin.ai";
128
+ const p = servicePrefix ?? (typeof process === "undefined" ? void 0 : process.env.NEXT_PUBLIC_SERVICE_PREFIX) ?? "https://";
129
+ const d = domain ?? (typeof process === "undefined" ? void 0 : process.env.NEXT_PUBLIC_DOMAIN) ?? "imajin.ai";
251
130
  if (p.includes("localhost") || d.includes("localhost")) {
252
131
  const port = getPort(name, "dev");
253
132
  return port ? `http://localhost:${port}` : `http://localhost:3000`;
@@ -258,6 +137,20 @@ function buildPublicUrl(name, servicePrefix, domain) {
258
137
  const match = p.replace(/^https?:\/\//, "").replace(/-$/, "") || void 0;
259
138
  return getPublicUrl(name, { prefix: match, domain: d });
260
139
  }
140
+ function buildPublicUrlAbsolute(name) {
141
+ if (typeof process !== "undefined") {
142
+ const explicit = process.env[`NEXT_PUBLIC_${name.toUpperCase()}_URL`];
143
+ if (explicit && /^https?:\/\//.test(explicit)) return explicit;
144
+ }
145
+ const prefix = (typeof process === "undefined" ? void 0 : process.env.NEXT_PUBLIC_SERVICE_PREFIX) ?? "https://";
146
+ const domain = (typeof process === "undefined" ? void 0 : process.env.NEXT_PUBLIC_DOMAIN) ?? "imajin.ai";
147
+ if (prefix.includes("localhost") || domain.includes("localhost")) {
148
+ const port = getPort(name, "dev");
149
+ return port ? `http://localhost:${port}` : "http://localhost:3000";
150
+ }
151
+ const match = prefix.replace(/^https?:\/\//, "").replace(/-$/, "") || void 0;
152
+ return getPublicUrl(name, { prefix: match, domain });
153
+ }
261
154
  var SERVICE_NAMES = SERVICES.map((s) => s.name);
262
155
  function servicesByTier(tier) {
263
156
  return SERVICES.filter((s) => s.tier === tier);
@@ -319,10 +212,45 @@ function apiFetch(path, init) {
319
212
  return fetch(apiUrl(path), init);
320
213
  }
321
214
 
215
+ // src/rate-limit.ts
216
+ var store = /* @__PURE__ */ new Map();
217
+ var lastGlobalCleanup = Date.now();
218
+ function pruneStore(now) {
219
+ if (now - lastGlobalCleanup < 6e4) return;
220
+ store.forEach((entry, key) => {
221
+ if (now - entry.lastSeen > 12e4) {
222
+ store.delete(key);
223
+ }
224
+ });
225
+ lastGlobalCleanup = now;
226
+ }
227
+ function rateLimit(key, limit, windowMs) {
228
+ const now = Date.now();
229
+ pruneStore(now);
230
+ const windowStart = now - windowMs;
231
+ const entry = store.get(key) ?? { timestamps: [], lastSeen: now };
232
+ entry.timestamps = entry.timestamps.filter((t) => t > windowStart);
233
+ entry.lastSeen = now;
234
+ if (entry.timestamps.length >= limit) {
235
+ const oldest = entry.timestamps[0];
236
+ const retryAfter = Math.ceil((oldest + windowMs - now) / 1e3);
237
+ store.set(key, entry);
238
+ return { limited: true, retryAfter: Math.max(1, retryAfter) };
239
+ }
240
+ entry.timestamps.push(now);
241
+ store.set(key, entry);
242
+ return { limited: false, retryAfter: 0 };
243
+ }
244
+ function getClientIP(request) {
245
+ const forwarded = request.headers.get("x-forwarded-for");
246
+ if (forwarded) return forwarded.split(",")[0].trim();
247
+ return "127.0.0.1";
248
+ }
249
+
322
250
  // src/handles.ts
323
251
  var HANDLE_PATTERN = /^[a-z0-9._-]{3,30}$/;
324
- var HANDLE_EDGE = /^[.\-]|[.\-]$/;
325
- var HANDLE_CONSECUTIVE = /[.\-]{2}/;
252
+ var HANDLE_EDGE = /^[.-]|[.-]$/;
253
+ var HANDLE_CONSECUTIVE = /[.-]{2}/;
326
254
  var HANDLE_INPUT_PATTERN = "[a-z0-9._\\-]{3,30}";
327
255
  var HANDLE_ALLOWED_CHARS = /[^a-z0-9._-]/g;
328
256
  var RESERVED_HANDLES = /* @__PURE__ */ new Set([
@@ -466,8 +394,8 @@ var docsPath = () => `/docs`;
466
394
  var projectPath = () => `/project`;
467
395
  var notifyPath = () => `/notify`;
468
396
  var notifySettingsPath = () => `/notify/settings`;
469
- // Annotate the CommonJS export names for ESM import in node:
470
- 0 && (module.exports = {
397
+ export {
398
+ ALLOWED_FRAME_ORIGINS,
471
399
  HANDLE_ALLOWED_CHARS,
472
400
  HANDLE_ERROR,
473
401
  HANDLE_INPUT_PATTERN,
@@ -503,6 +431,7 @@ var notifySettingsPath = () => `/notify/settings`;
503
431
  bugsPath,
504
432
  buildPath,
505
433
  buildPublicUrl,
434
+ buildPublicUrlAbsolute,
506
435
  buildServiceUrlMap,
507
436
  bumpPath,
508
437
  chatConversationPath,
@@ -539,6 +468,7 @@ var notifySettingsPath = () => `/notify/settings`;
539
468
  eventsCheckoutSuccessPath,
540
469
  eventsCreatePath,
541
470
  eventsDashboardPath,
471
+ getClientIP,
542
472
  getPort,
543
473
  getPublicUrl,
544
474
  getService,
@@ -582,12 +512,16 @@ var notifySettingsPath = () => `/notify/settings`;
582
512
  profileRegisterPath,
583
513
  profileUrl,
584
514
  projectPath,
515
+ rateLimit,
585
516
  registryDocsPath,
586
517
  registryPath,
587
518
  servicesByTier,
588
519
  servicesByVisibility,
589
520
  subscribePath,
521
+ tier1Headers,
522
+ tier2Headers,
523
+ tier3Headers,
590
524
  validateOrigin,
591
525
  whitepaperPath,
592
526
  withCors
593
- });
527
+ };
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Security-header helpers for next.config.js (CommonJS bridge).
5
+ *
6
+ * The frame-ancestors value is derived from ALLOWED_FRAME_ORIGINS in src/cors.ts
7
+ * and the localhost dev behaviour mirrors isAllowedOrigin() in the same file.
8
+ * If the allowed-origin set changes, update src/cors.ts (ORIGIN_PATTERN +
9
+ * ALLOWED_FRAME_ORIGINS) and the constant below together.
10
+ */
11
+
12
+ // Mirrors ALLOWED_FRAME_ORIGINS in src/cors.ts
13
+ const ALLOWED_FRAME_ORIGINS = 'https://*.imajin.ai https://imajin.ai';
14
+
15
+ /**
16
+ * Build the frame-ancestors directive value.
17
+ * Adds localhost:* in non-prod, mirroring isAllowedOrigin().
18
+ */
19
+ function buildFrameAncestors() {
20
+ const local = process.env.NODE_ENV !== 'production' ? ' http://localhost:*' : '';
21
+ return `frame-ancestors 'self' ${ALLOWED_FRAME_ORIGINS}${local}`;
22
+ }
23
+
24
+ /**
25
+ * Tier 1 — hard deny framing.
26
+ * Use on credential and auth-sensitive pages that must never be embedded.
27
+ * @returns {Array<{key: string, value: string}>}
28
+ */
29
+ function tier1Headers() {
30
+ return [
31
+ { key: 'Content-Security-Policy', value: "frame-ancestors 'none'" },
32
+ { key: 'X-Frame-Options', value: 'DENY' },
33
+ ];
34
+ }
35
+
36
+ /**
37
+ * Tier 2 — allow framing by Imajin origins only.
38
+ * Use on routes embedded by the auth hub (service dashboards, document viewer, surveys).
39
+ * Intentionally omits X-Frame-Options — it cannot express an allow-list.
40
+ * @returns {Array<{key: string, value: string}>}
41
+ */
42
+ function tier2Headers() {
43
+ return [{ key: 'Content-Security-Policy', value: buildFrameAncestors() }];
44
+ }
45
+
46
+ /**
47
+ * Tier 3 — baseline hardening. Safe to apply on every route in every app.
48
+ * @returns {Array<{key: string, value: string}>}
49
+ */
50
+ function tier3Headers() {
51
+ return [
52
+ { key: 'X-Content-Type-Options', value: 'nosniff' },
53
+ { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
54
+ ];
55
+ }
56
+
57
+ module.exports = { tier1Headers, tier2Headers, tier3Headers };
package/package.json CHANGED
@@ -1,21 +1,31 @@
1
1
  {
2
2
  "name": "@ima-jin/config",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
+ "description": "Shared configuration for Imajin services — CORS, service routing, session config, handle validation, and route helpers",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/ima-jin/imajin-ai.git",
9
+ "directory": "packages/config"
10
+ },
11
+ "type": "module",
4
12
  "main": "./dist/index.js",
5
13
  "types": "./dist/index.d.ts",
6
14
  "exports": {
7
15
  ".": {
8
- "import": "./dist/index.mjs",
9
- "require": "./dist/index.js",
10
- "types": "./dist/index.d.ts"
11
- }
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js",
18
+ "require": "./dist/index.cjs"
19
+ },
20
+ "./next-headers": "./next-headers.cjs"
12
21
  },
13
22
  "files": [
14
23
  "dist/",
15
- "src/"
24
+ "src/",
25
+ "next-headers.cjs"
16
26
  ],
17
27
  "dependencies": {
18
- "@ima-jin/tokens": "^0.6.1"
28
+ "@ima-jin/tokens": "^0.8.0"
19
29
  },
20
30
  "peerDependencies": {
21
31
  "next": ">=14"
package/src/base-path.ts CHANGED
@@ -4,20 +4,20 @@
4
4
  * Next.js `basePath` auto-prefixes <Link> and router.push() but NOT:
5
5
  * - fetch() calls
6
6
  * - <a href=""> tags
7
- * - window.location assignments
7
+ * - globalThis.location assignments
8
8
  *
9
9
  * These utilities read NEXT_PUBLIC_BASE_PATH (set automatically by Next.js
10
10
  * when basePath is configured) and prepend it where needed.
11
11
  *
12
12
  * Usage:
13
- * import { apiFetch, apiUrl } from '@imajin/config';
13
+ * import { apiFetch, apiUrl } from '@ima-jin/config';
14
14
  *
15
15
  * // Instead of: fetch('/api/listings')
16
16
  * apiFetch('/api/listings')
17
17
  *
18
- * // For <a> tags or window.location:
18
+ * // For <a> tags or globalThis.location:
19
19
  * <a href={apiUrl('/dashboard')}>Dashboard</a>
20
- * window.location.href = apiUrl('/dashboard');
20
+ * globalThis.location.href = apiUrl('/dashboard');
21
21
  */
22
22
 
23
23
  /**
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;
package/src/handles.ts CHANGED
@@ -10,10 +10,10 @@
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
19
  export const HANDLE_INPUT_PATTERN = '[a-z0-9._\\-]{3,30}';
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
- export { isAllowedOrigin, corsHeaders, corsOptions, withCors, validateOrigin } from "./cors";
1
+ export { isAllowedOrigin, corsHeaders, corsOptions, withCors, validateOrigin, ALLOWED_FRAME_ORIGINS } from "./cors";
2
+ export { tier1Headers, tier2Headers, tier3Headers } from './security-headers';
2
3
 
3
4
  export {
4
5
  SERVICES,
@@ -8,6 +9,7 @@ export {
8
9
  getServiceUrl,
9
10
  getPublicUrl,
10
11
  buildPublicUrl,
12
+ buildPublicUrlAbsolute,
11
13
  servicesByTier,
12
14
  servicesByVisibility,
13
15
  buildServiceUrlMap,
@@ -27,6 +29,8 @@ export {
27
29
 
28
30
  export { apiFetch, apiUrl } from "./base-path";
29
31
 
32
+ export { rateLimit, getClientIP } from "./rate-limit";
33
+
30
34
  export {
31
35
  HANDLE_PATTERN,
32
36
  HANDLE_INPUT_PATTERN,
@@ -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,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
+ }