@mohasinac/appkit 4.73.0 → 4.75.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/.build-stamp.json +3 -3
- package/dist/http/ApiClient.js +20 -1
- package/dist/http/index.d.ts +2 -0
- package/dist/http/index.js +7 -0
- package/dist/http/service-origins.d.ts +68 -0
- package/dist/http/service-origins.js +106 -0
- package/dist/next/routing/portals.js +37 -2
- package/package.json +1 -1
package/dist/.build-stamp.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"stampVersion": 1,
|
|
3
|
-
"version": "4.
|
|
4
|
-
"head": "
|
|
3
|
+
"version": "4.75.0",
|
|
4
|
+
"head": "c5426d7535f8b4331464486d52298a6f668168ee",
|
|
5
5
|
"branch": "main",
|
|
6
6
|
"dirty": true,
|
|
7
|
-
"builtAt": "2026-11-
|
|
7
|
+
"builtAt": "2026-11-09T07:08:58.762Z"
|
|
8
8
|
}
|
package/dist/http/ApiClient.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { serviceOriginFor } from "./service-origins.js";
|
|
1
2
|
/**
|
|
2
3
|
* ApiClient — @mohasinac/http
|
|
3
4
|
*
|
|
@@ -34,7 +35,25 @@ export class ApiClient {
|
|
|
34
35
|
this.defaultTimeout = options?.defaultTimeout ?? 120000;
|
|
35
36
|
}
|
|
36
37
|
buildURL(endpoint, params) {
|
|
37
|
-
|
|
38
|
+
/*
|
|
39
|
+
* 🛑 A registered SERVICE ORIGIN wins over this client's own base.
|
|
40
|
+
*
|
|
41
|
+
* `/api/blog/*` and `/api/events/*` are served by their own deployments
|
|
42
|
+
* now. A consumer that wants its data from the owning subdomain registers
|
|
43
|
+
* the prefix once at boot and every call site is routed without edit —
|
|
44
|
+
* which is the point of the seam (owner, 2026-11-09): the consumer keeps
|
|
45
|
+
* its presentation and changes only where the DATA comes from.
|
|
46
|
+
*
|
|
47
|
+
* Order matters. The service map is consulted FIRST because it is the more
|
|
48
|
+
* specific statement: `baseURL` says "where this client points by
|
|
49
|
+
* default", the map says "who owns this path", and a path with a declared
|
|
50
|
+
* owner is owned regardless of which client asked.
|
|
51
|
+
*
|
|
52
|
+
* An unregistered prefix returns `undefined` and nothing below changes —
|
|
53
|
+
* an install that has not adopted the seam behaves exactly as before.
|
|
54
|
+
*/
|
|
55
|
+
const origin = serviceOriginFor(endpoint) ||
|
|
56
|
+
this.baseURL ||
|
|
38
57
|
(typeof window !== "undefined"
|
|
39
58
|
? window.location.origin
|
|
40
59
|
: (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"));
|
package/dist/http/index.d.ts
CHANGED
|
@@ -7,4 +7,6 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export { ApiClient, ApiClientError, apiClient } from "./ApiClient";
|
|
9
9
|
export type { ApiClientOptions, RequestConfig, ApiResponse } from "./ApiClient";
|
|
10
|
+
export { configureServiceOrigins, getServiceOrigins, serviceOriginFor, _resetServiceOrigins, } from "./service-origins";
|
|
11
|
+
export type { ServiceOriginMap } from "./service-origins";
|
|
10
12
|
export { withRetry } from "./retry";
|
package/dist/http/index.js
CHANGED
|
@@ -6,4 +6,11 @@
|
|
|
6
6
|
* Accepts baseURL as a constructor option instead.
|
|
7
7
|
*/
|
|
8
8
|
export { ApiClient, ApiClientError, apiClient } from "./ApiClient.js";
|
|
9
|
+
/*
|
|
10
|
+
* The subdomain seam. `configureServiceOrigins` is how a consumer points
|
|
11
|
+
* `/api/blog/*` or `/api/events/*` at the deployment that OWNS them without
|
|
12
|
+
* editing a single call site. Registering nothing keeps same-origin behaviour
|
|
13
|
+
* exactly, so adoption is opt-in per deployment and per prefix.
|
|
14
|
+
*/
|
|
15
|
+
export { configureServiceOrigins, getServiceOrigins, serviceOriginFor, _resetServiceOrigins, } from "./service-origins.js";
|
|
9
16
|
export { withRetry } from "./retry.js";
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which ORIGIN serves a given API path prefix.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* `/api/blog/*` and `/api/events/*` are no longer served by the shop — they
|
|
7
|
+
* moved to their own deployments, and the owner's rule for the split is
|
|
8
|
+
* (2026-11-09):
|
|
9
|
+
*
|
|
10
|
+
* > *"let the events or subdomains give APIs for the CRUD operations so
|
|
11
|
+
* > anywhere can use them, while keeping the carousel slide logic in the
|
|
12
|
+
* > main."*
|
|
13
|
+
*
|
|
14
|
+
* So a consumer keeps its own presentation and changes only where the DATA
|
|
15
|
+
* comes from. That is a one-line change at the data source — **if** the client
|
|
16
|
+
* knows which host owns which prefix. This registry is that knowledge.
|
|
17
|
+
*
|
|
18
|
+
* ## 🛑 It is CONFIG, not code
|
|
19
|
+
*
|
|
20
|
+
* There is no hostname in this file and there must never be one. A deployment
|
|
21
|
+
* registers its own map at boot from env; an install that registers nothing
|
|
22
|
+
* keeps today's behaviour exactly — every path resolves against the current
|
|
23
|
+
* origin, which is what every consumer did before this existed.
|
|
24
|
+
*
|
|
25
|
+
* That "registers nothing" default is the whole safety story. Adopting the
|
|
26
|
+
* seam is opt-in per deployment and per prefix, so the shop can move `/api/blog`
|
|
27
|
+
* to the subdomain while `/api/events` still resolves locally, verify, and then
|
|
28
|
+
* move the second one. Three small commits instead of one risky switch.
|
|
29
|
+
*
|
|
30
|
+
* ## 🛑 Longest prefix wins, and the trailing-boundary check is not optional
|
|
31
|
+
*
|
|
32
|
+
* `/api/blog` must not match `/api/blogger`. Matching on a bare
|
|
33
|
+
* `startsWith(prefix)` would route an unrelated endpoint to a host that has
|
|
34
|
+
* never heard of it, and the failure is a 404 from the WRONG server — which
|
|
35
|
+
* reads as "the data is gone" rather than "the routing is wrong". So a match
|
|
36
|
+
* requires the next character to be `/` or `?` or end-of-string.
|
|
37
|
+
*
|
|
38
|
+
* ## What this deliberately does NOT do
|
|
39
|
+
*
|
|
40
|
+
* It does not make the request cross-origin-safe. Pointing a browser at
|
|
41
|
+
* another origin needs CORS on that origin and credentials handling for the
|
|
42
|
+
* session cookie. Both subdomains sit under one registrable domain and the
|
|
43
|
+
* session cookie is widened to it, so a `credentials: "include"` call works —
|
|
44
|
+
* but the CORS headers are the receiving app's job, not this file's.
|
|
45
|
+
*/
|
|
46
|
+
/** `{ "/api/blog": "https://blogs.example.com" }` — prefix → absolute origin. */
|
|
47
|
+
export type ServiceOriginMap = Readonly<Record<string, string>>;
|
|
48
|
+
/**
|
|
49
|
+
* Register the prefix → origin map. Idempotent; last call wins.
|
|
50
|
+
*
|
|
51
|
+
* Entries with a blank value are DROPPED rather than registered, because the
|
|
52
|
+
* value normally comes straight from an env var and an unset var must mean
|
|
53
|
+
* "same origin" — not "resolve against the empty string". A deployment that
|
|
54
|
+
* has not configured the seam yet must behave exactly as it did before.
|
|
55
|
+
*/
|
|
56
|
+
export declare function configureServiceOrigins(map: ServiceOriginMap): void;
|
|
57
|
+
/** Current map, for diagnostics and admin screens. */
|
|
58
|
+
export declare function getServiceOrigins(): ServiceOriginMap;
|
|
59
|
+
/**
|
|
60
|
+
* The origin that serves `path`, or `undefined` for "this origin".
|
|
61
|
+
*
|
|
62
|
+
* `undefined` rather than a default is deliberate: the caller already has a
|
|
63
|
+
* correct same-origin fallback, and returning one from here would hide an
|
|
64
|
+
* unconfigured prefix behind a plausible value.
|
|
65
|
+
*/
|
|
66
|
+
export declare function serviceOriginFor(path: string): string | undefined;
|
|
67
|
+
/** Drop all registrations. Tests only. */
|
|
68
|
+
export declare function _resetServiceOrigins(): void;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which ORIGIN serves a given API path prefix.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* `/api/blog/*` and `/api/events/*` are no longer served by the shop — they
|
|
7
|
+
* moved to their own deployments, and the owner's rule for the split is
|
|
8
|
+
* (2026-11-09):
|
|
9
|
+
*
|
|
10
|
+
* > *"let the events or subdomains give APIs for the CRUD operations so
|
|
11
|
+
* > anywhere can use them, while keeping the carousel slide logic in the
|
|
12
|
+
* > main."*
|
|
13
|
+
*
|
|
14
|
+
* So a consumer keeps its own presentation and changes only where the DATA
|
|
15
|
+
* comes from. That is a one-line change at the data source — **if** the client
|
|
16
|
+
* knows which host owns which prefix. This registry is that knowledge.
|
|
17
|
+
*
|
|
18
|
+
* ## 🛑 It is CONFIG, not code
|
|
19
|
+
*
|
|
20
|
+
* There is no hostname in this file and there must never be one. A deployment
|
|
21
|
+
* registers its own map at boot from env; an install that registers nothing
|
|
22
|
+
* keeps today's behaviour exactly — every path resolves against the current
|
|
23
|
+
* origin, which is what every consumer did before this existed.
|
|
24
|
+
*
|
|
25
|
+
* That "registers nothing" default is the whole safety story. Adopting the
|
|
26
|
+
* seam is opt-in per deployment and per prefix, so the shop can move `/api/blog`
|
|
27
|
+
* to the subdomain while `/api/events` still resolves locally, verify, and then
|
|
28
|
+
* move the second one. Three small commits instead of one risky switch.
|
|
29
|
+
*
|
|
30
|
+
* ## 🛑 Longest prefix wins, and the trailing-boundary check is not optional
|
|
31
|
+
*
|
|
32
|
+
* `/api/blog` must not match `/api/blogger`. Matching on a bare
|
|
33
|
+
* `startsWith(prefix)` would route an unrelated endpoint to a host that has
|
|
34
|
+
* never heard of it, and the failure is a 404 from the WRONG server — which
|
|
35
|
+
* reads as "the data is gone" rather than "the routing is wrong". So a match
|
|
36
|
+
* requires the next character to be `/` or `?` or end-of-string.
|
|
37
|
+
*
|
|
38
|
+
* ## What this deliberately does NOT do
|
|
39
|
+
*
|
|
40
|
+
* It does not make the request cross-origin-safe. Pointing a browser at
|
|
41
|
+
* another origin needs CORS on that origin and credentials handling for the
|
|
42
|
+
* session cookie. Both subdomains sit under one registrable domain and the
|
|
43
|
+
* session cookie is widened to it, so a `credentials: "include"` call works —
|
|
44
|
+
* but the CORS headers are the receiving app's job, not this file's.
|
|
45
|
+
*/
|
|
46
|
+
/**
|
|
47
|
+
* Module state, like appkit's brand and baseline resolvers. Server and browser
|
|
48
|
+
* are two module graphs, so each registers its own copy at boot — see the note
|
|
49
|
+
* in any consumer's `providers.config.ts` about why both injection points are
|
|
50
|
+
* required and neither is redundant.
|
|
51
|
+
*/
|
|
52
|
+
let originsByPrefix = {};
|
|
53
|
+
/** Longest-first, so `/api/blog/admin` beats `/api/blog` when both are set. */
|
|
54
|
+
let prefixesByLength = [];
|
|
55
|
+
/**
|
|
56
|
+
* Register the prefix → origin map. Idempotent; last call wins.
|
|
57
|
+
*
|
|
58
|
+
* Entries with a blank value are DROPPED rather than registered, because the
|
|
59
|
+
* value normally comes straight from an env var and an unset var must mean
|
|
60
|
+
* "same origin" — not "resolve against the empty string". A deployment that
|
|
61
|
+
* has not configured the seam yet must behave exactly as it did before.
|
|
62
|
+
*/
|
|
63
|
+
export function configureServiceOrigins(map) {
|
|
64
|
+
const cleaned = {};
|
|
65
|
+
for (const [prefix, origin] of Object.entries(map)) {
|
|
66
|
+
const p = prefix.trim();
|
|
67
|
+
const o = origin?.trim();
|
|
68
|
+
if (!p.startsWith("/") || !o)
|
|
69
|
+
continue;
|
|
70
|
+
cleaned[p.replace(/\/+$/, "")] = o.replace(/\/+$/, "");
|
|
71
|
+
}
|
|
72
|
+
originsByPrefix = cleaned;
|
|
73
|
+
prefixesByLength = Object.keys(cleaned).sort((a, b) => b.length - a.length);
|
|
74
|
+
}
|
|
75
|
+
/** Current map, for diagnostics and admin screens. */
|
|
76
|
+
export function getServiceOrigins() {
|
|
77
|
+
return originsByPrefix;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The origin that serves `path`, or `undefined` for "this origin".
|
|
81
|
+
*
|
|
82
|
+
* `undefined` rather than a default is deliberate: the caller already has a
|
|
83
|
+
* correct same-origin fallback, and returning one from here would hide an
|
|
84
|
+
* unconfigured prefix behind a plausible value.
|
|
85
|
+
*/
|
|
86
|
+
export function serviceOriginFor(path) {
|
|
87
|
+
if (!prefixesByLength.length)
|
|
88
|
+
return undefined;
|
|
89
|
+
// Compare on the pathname only — a query string must not affect routing.
|
|
90
|
+
const pathname = path.split(/[?#]/)[0] ?? path;
|
|
91
|
+
for (const prefix of prefixesByLength) {
|
|
92
|
+
if (!pathname.startsWith(prefix))
|
|
93
|
+
continue;
|
|
94
|
+
// 🛑 Boundary check — see the header. `/api/blog` must not claim
|
|
95
|
+
// `/api/blogger`.
|
|
96
|
+
const next = pathname.charAt(prefix.length);
|
|
97
|
+
if (next === "" || next === "/")
|
|
98
|
+
return originsByPrefix[prefix];
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
/** Drop all registrations. Tests only. */
|
|
103
|
+
export function _resetServiceOrigins() {
|
|
104
|
+
originsByPrefix = {};
|
|
105
|
+
prefixesByLength = [];
|
|
106
|
+
}
|
|
@@ -64,6 +64,29 @@ export const PORTAL_DEFINITIONS = [
|
|
|
64
64
|
* this module exists to end.
|
|
65
65
|
*/
|
|
66
66
|
{ label: "auth", section: "/auth", roles: [], home: "/auth/login" },
|
|
67
|
+
/*
|
|
68
|
+
* ── help: a PORTAL, not a deployment (owner, 2026-11-09) ──────────────
|
|
69
|
+
*
|
|
70
|
+
* The plan had `help.<domain>` down as its own app alongside blogs and
|
|
71
|
+
* events. The owner's answer was "do like dashboards" — i.e. a host rewrite
|
|
72
|
+
* onto the main app, the same treatment `admin.`/`seller.`/`user.` get, and
|
|
73
|
+
* NOT a deployment.
|
|
74
|
+
*
|
|
75
|
+
* It is the right call for this surface: `/help` is six content pages with
|
|
76
|
+
* ZERO API routes (measured 2026-11-09). Everything the subdomain split buys
|
|
77
|
+
* — an independently deployable owner of its own data, exposing CRUD over
|
|
78
|
+
* HTTP — is worth nothing when there is no data and no CRUD. It would be a
|
|
79
|
+
* whole deployment, Vercel project, env set and CORS gate to serve six static
|
|
80
|
+
* pages the shop already renders.
|
|
81
|
+
*
|
|
82
|
+
* Consequently `help` is REMOVED from DEPLOYMENT_LABELS below. A label must
|
|
83
|
+
* be in exactly one of the two lists: in both, the deployment check wins and
|
|
84
|
+
* the rewrite silently never fires.
|
|
85
|
+
*
|
|
86
|
+
* `roles: []` like `auth` — the help centre is public, and a visitor with no
|
|
87
|
+
* account is precisely who needs it.
|
|
88
|
+
*/
|
|
89
|
+
{ label: "help", section: "/help", roles: [] },
|
|
67
90
|
];
|
|
68
91
|
/**
|
|
69
92
|
* Hosts that are their OWN DEPLOYMENT, not a portal of this app.
|
|
@@ -93,8 +116,20 @@ export const DEPLOYMENT_LABELS = [
|
|
|
93
116
|
"blogs",
|
|
94
117
|
"live",
|
|
95
118
|
"media",
|
|
96
|
-
|
|
97
|
-
|
|
119
|
+
/*
|
|
120
|
+
* 🛑 `help` WAS HERE and has moved to PORTAL_DEFINITIONS above — the
|
|
121
|
+
* owner chose a host rewrite over a deployment for it (2026-11-09). A label
|
|
122
|
+
* in BOTH lists is the bug this note exists to prevent: `isDeploymentHost`
|
|
123
|
+
* is consulted first, so the portal rewrite would silently never fire and
|
|
124
|
+
* `help.<domain>` would fall through to the homepage — exactly the duplicate
|
|
125
|
+
* content CLAUDE.md records for `audit.` and `tools.`.
|
|
126
|
+
*
|
|
127
|
+
* ⚠ `chat` IS ALSO ABSENT, and for a different reason: there is nothing to
|
|
128
|
+
* serve. `/chat` has ZERO pages in the shop (measured 2026-11-09), so making
|
|
129
|
+
* it a portal would rewrite `chat.<domain>` onto a 404, and making it a
|
|
130
|
+
* deployment would point a hostname at an app that does not exist. It joins
|
|
131
|
+
* one list or the other when it has a surface — not before.
|
|
132
|
+
*/
|
|
98
133
|
];
|
|
99
134
|
/** Is this hostname a separate deployment rather than a portal of this app? */
|
|
100
135
|
export function isDeploymentHost(hostname) {
|