@lobehub/edge-contract 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,16 +1,20 @@
1
1
  # @lobehub/edge-contract
2
2
 
3
3
  Edge logic that more than one LobeHub repository has to agree on, kept in exactly one place.
4
+ The gateway, landing, lobehub-cloud and lobe-chat import it instead of carrying copies.
4
5
 
5
- - `fixtures/*.json` — golden cases (`{ cases: [{ name, input, expected }] }`) generated from the
6
- authority implementation of each piece of logic. Every repository that still carries its own
7
- copy runs that copy against these cases until it switches to importing from here.
8
- - `src/*` — the shared implementations, one subpath export each.
6
+ | Subpath | What it owns |
7
+ | --- | --- |
8
+ | `./landing-locales` | The URL path prefixes landing serves, and the default (`en`, at the bare path). |
9
+ | `./seo-topics` | The discover topics landing server-renders for `?q=`. |
10
+ | `./request-locale` | The app locale list and how a request picks one (`hl`, then the `LOBE_LOCALE` cookie, then Accept-Language). |
11
+ | `./last-workspace-cookie` | The `LOBE_LAST_WORKSPACE` cookie format. |
12
+ | `./acquisition-context` | The signed `lh_acq` first-touch cookie, landing click ids and the attribution params lifted onto `/signin`. |
9
13
 
10
- Design: `lobehub-gateway/docs/ryugi/specs/2026-11-06-logic-dedupe-design.md`.
14
+ `fixtures/*.json` are golden cases (`{ cases: [{ name, input, expected }] }`) captured from the
15
+ implementations this package replaced. They are frozen: a change that fails one is a behavior
16
+ change every consumer will see, so update the case on purpose, in the same commit.
11
17
 
12
- Regenerate fixtures from the authority repositories (checked out as siblings of this one):
18
+ Design: `lobehub-gateway/docs/ryugi/specs/2026-11-06-logic-dedupe-design.md`.
13
19
 
14
- ```bash
15
- pnpm generate:fixtures
16
- ```
20
+ Releases: bump `version`, push a `v<version>` tag; CI publishes to npm.
@@ -0,0 +1,12 @@
1
+ export declare const ACQUISITION_CONTEXT_COOKIE = "lh_acq";
2
+ export declare const ACQUISITION_CONTEXT_MAX_AGE_SECONDS: number;
3
+ export interface AcquisitionContext {
4
+ capturedAt: string;
5
+ lhCid: string;
6
+ }
7
+ export declare const parseLandingClickId: (value: unknown) => string | undefined;
8
+ export declare const captureAcquisitionContext: (request: Pick<Request, "url">, now?: Date) => AcquisitionContext | null;
9
+ export declare const signAcquisitionContext: (context: AcquisitionContext, secret: string) => Promise<string>;
10
+ export declare const verifyAcquisitionContext: (token: string, secret: string, now?: Date) => Promise<AcquisitionContext | null>;
11
+ export declare const readAcquisitionContextCookie: (cookieHeader: string | null) => string | null;
12
+ export declare const ATTRIBUTION_PARAMS: ReadonlySet<string>;
@@ -0,0 +1,175 @@
1
+ export const ACQUISITION_CONTEXT_COOKIE = 'lh_acq';
2
+ export const ACQUISITION_CONTEXT_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
3
+ const ACQUISITION_CONTEXT_VERSION = 1;
4
+ const MAX_COOKIE_LENGTH = 1024;
5
+ const MAX_FUTURE_SKEW_MS = 5 * 60 * 1000;
6
+ const SIGNING_SCOPE = 'lh-acquisition-context.v1:';
7
+ const textDecoder = new TextDecoder();
8
+ const textEncoder = new TextEncoder();
9
+ const AUTH_PATHS = new Set(['/signin', '/signup']);
10
+ // Landing issues lh_cid with crypto.randomUUID(). Treat it only as an opaque correlation ID.
11
+ const LH_CID_PATTERN = /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/i;
12
+ export const parseLandingClickId = (value) => {
13
+ if (typeof value !== 'string' || !LH_CID_PATTERN.test(value))
14
+ return;
15
+ return value.toLowerCase();
16
+ };
17
+ const sanitizeCapturedAt = (value) => {
18
+ if (typeof value !== 'string')
19
+ return;
20
+ const timestamp = Date.parse(value);
21
+ if (!Number.isFinite(timestamp))
22
+ return;
23
+ return new Date(timestamp).toISOString();
24
+ };
25
+ const sanitizeContext = (value) => {
26
+ if (!value || typeof value !== 'object')
27
+ return null;
28
+ const candidate = value;
29
+ if (candidate.version !== ACQUISITION_CONTEXT_VERSION)
30
+ return null;
31
+ const capturedAt = sanitizeCapturedAt(candidate.capturedAt);
32
+ const lhCid = parseLandingClickId(candidate.lhCid);
33
+ if (!capturedAt || !lhCid)
34
+ return null;
35
+ return { capturedAt, lhCid };
36
+ };
37
+ const serializeContext = (context) => ({
38
+ capturedAt: context.capturedAt,
39
+ lhCid: context.lhCid,
40
+ version: ACQUISITION_CONTEXT_VERSION,
41
+ });
42
+ const encodeBase64Url = (value) => {
43
+ let binary = '';
44
+ for (const byte of value)
45
+ binary += String.fromCharCode(byte);
46
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
47
+ };
48
+ const decodeBase64Url = (value) => {
49
+ if (!/^[\w-]+$/.test(value))
50
+ throw new Error('Invalid base64url value');
51
+ const base64 = value.replaceAll('-', '+').replaceAll('_', '/');
52
+ const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=');
53
+ const binary = atob(padded);
54
+ const decoded = new Uint8Array(binary.length);
55
+ for (let index = 0; index < binary.length; index += 1) {
56
+ decoded[index] = binary.charCodeAt(index);
57
+ }
58
+ return decoded;
59
+ };
60
+ const importSigningKey = (secret, usages) => crypto.subtle.importKey('raw', textEncoder.encode(secret), { hash: 'SHA-256', name: 'HMAC' }, false, usages);
61
+ const getSigningInput = (payload) => textEncoder.encode(`${SIGNING_SCOPE}${payload}`).buffer;
62
+ const getCaptureUrl = (requestUrl) => {
63
+ if (!AUTH_PATHS.has(requestUrl.pathname))
64
+ return requestUrl;
65
+ const callbackUrl = requestUrl.searchParams.get('callbackUrl');
66
+ if (!callbackUrl)
67
+ return requestUrl;
68
+ try {
69
+ const parsed = new URL(callbackUrl, requestUrl.origin);
70
+ return parsed.origin === requestUrl.origin ? parsed : requestUrl;
71
+ }
72
+ catch {
73
+ return requestUrl;
74
+ }
75
+ };
76
+ export const captureAcquisitionContext = (request, now = new Date()) => {
77
+ const requestUrl = new URL(request.url);
78
+ const captureUrl = getCaptureUrl(requestUrl);
79
+ const lhCid = parseLandingClickId(captureUrl.searchParams.get('lh_cid')) ??
80
+ parseLandingClickId(requestUrl.searchParams.get('lh_cid'));
81
+ if (!lhCid)
82
+ return null;
83
+ return {
84
+ capturedAt: now.toISOString(),
85
+ lhCid,
86
+ };
87
+ };
88
+ export const signAcquisitionContext = async (context, secret) => {
89
+ if (!secret)
90
+ throw new Error('Acquisition context signing secret is required');
91
+ const sanitized = sanitizeContext({
92
+ ...context,
93
+ version: ACQUISITION_CONTEXT_VERSION,
94
+ });
95
+ if (!sanitized)
96
+ throw new Error('Invalid acquisition context');
97
+ const payload = encodeBase64Url(textEncoder.encode(JSON.stringify(serializeContext(sanitized))));
98
+ const key = await importSigningKey(secret, ['sign']);
99
+ const signature = new Uint8Array(await crypto.subtle.sign('HMAC', key, getSigningInput(payload)));
100
+ const token = `${payload}.${encodeBase64Url(signature)}`;
101
+ if (token.length > MAX_COOKIE_LENGTH)
102
+ throw new Error('Acquisition context cookie is too large');
103
+ return token;
104
+ };
105
+ export const verifyAcquisitionContext = async (token, secret, now = new Date()) => {
106
+ if (!token || !secret || token.length > MAX_COOKIE_LENGTH)
107
+ return null;
108
+ try {
109
+ const parts = token.split('.');
110
+ if (parts.length !== 2)
111
+ return null;
112
+ const [payload, encodedSignature] = parts;
113
+ const key = await importSigningKey(secret, ['verify']);
114
+ const valid = await crypto.subtle.verify('HMAC', key, decodeBase64Url(encodedSignature), getSigningInput(payload));
115
+ if (!valid)
116
+ return null;
117
+ const context = sanitizeContext(JSON.parse(textDecoder.decode(decodeBase64Url(payload))));
118
+ if (!context)
119
+ return null;
120
+ const capturedAt = Date.parse(context.capturedAt);
121
+ const nowTimestamp = now.getTime();
122
+ if (capturedAt > nowTimestamp + MAX_FUTURE_SKEW_MS ||
123
+ nowTimestamp - capturedAt > ACQUISITION_CONTEXT_MAX_AGE_SECONDS * 1000) {
124
+ return null;
125
+ }
126
+ return context;
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ };
132
+ export const readAcquisitionContextCookie = (cookieHeader) => {
133
+ if (!cookieHeader)
134
+ return null;
135
+ for (const part of cookieHeader.split(';')) {
136
+ const separator = part.indexOf('=');
137
+ if (separator < 0)
138
+ continue;
139
+ const name = part.slice(0, separator).trim();
140
+ if (name !== ACQUISITION_CONTEXT_COOKIE)
141
+ continue;
142
+ const value = part.slice(separator + 1).trim();
143
+ try {
144
+ return decodeURIComponent(value);
145
+ }
146
+ catch {
147
+ return null;
148
+ }
149
+ }
150
+ return null;
151
+ };
152
+ // Non-utm_* attribution params posthog-js auto-captures (its CAMPAIGN_PARAMS minus the five
153
+ // utm_* keys). Whoever redirects a visitor to /signin lifts these, plus every utm_*, onto the
154
+ // top-level URL so PostHog's $pageview there records them.
155
+ export const ATTRIBUTION_PARAMS = new Set([
156
+ 'gad_source',
157
+ 'mc_cid',
158
+ 'gclid',
159
+ 'gclsrc',
160
+ 'dclid',
161
+ 'gbraid',
162
+ 'wbraid',
163
+ 'fbclid',
164
+ 'msclkid',
165
+ 'twclid',
166
+ 'li_fat_id',
167
+ 'igshid',
168
+ 'ttclid',
169
+ 'rdt_cid',
170
+ 'epik',
171
+ 'qclid',
172
+ 'sccid',
173
+ 'irclid',
174
+ '_kx',
175
+ ]);
@@ -0,0 +1,7 @@
1
+ export declare const LAST_WORKSPACE_COOKIE = "LOBE_LAST_WORKSPACE";
2
+ export declare const LAST_WORKSPACE_COOKIE_MAX_AGE: number;
3
+ export declare const LAST_WORKSPACE_SLUG_PATTERN: RegExp;
4
+ export declare const LAST_WORKSPACE_SLUG_MIN = 3;
5
+ export declare const LAST_WORKSPACE_SLUG_MAX = 32;
6
+ export declare const formatLastWorkspaceCookie: (userId: string, slug: string) => string | null;
7
+ export declare const parseLastWorkspaceCookie: (raw: string | undefined | null, userId: string | undefined | null) => string | null;
@@ -0,0 +1,30 @@
1
+ // Not httpOnly: the client rewrites it on every explicit workspace switch. It carries no
2
+ // authority (workspace scope is the `X-Workspace-Id` header, checked against membership), so
3
+ // a forged value only sends its own browser to a URL that 404s.
4
+ export const LAST_WORKSPACE_COOKIE = 'LOBE_LAST_WORKSPACE';
5
+ export const LAST_WORKSPACE_COOKIE_MAX_AGE = 60 * 60 * 24 * 90;
6
+ // The same shape as lobehub-cloud's WORKSPACE_SLUG_PATTERN / _MIN / _MAX, kept here rather than
7
+ // imported because that module also carries the reserved-slug tables, which no edge bundle
8
+ // should pay for. Anything failing it is ignored rather than sanitized, so a tampered cookie
9
+ // cannot smuggle `../`, a protocol or CRLF into a Location header.
10
+ export const LAST_WORKSPACE_SLUG_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
11
+ export const LAST_WORKSPACE_SLUG_MIN = 3;
12
+ export const LAST_WORKSPACE_SLUG_MAX = 32;
13
+ const isValidSlug = (slug) => slug.length >= LAST_WORKSPACE_SLUG_MIN &&
14
+ slug.length <= LAST_WORKSPACE_SLUG_MAX &&
15
+ LAST_WORKSPACE_SLUG_PATTERN.test(slug);
16
+ export const formatLastWorkspaceCookie = (userId, slug) => isValidSlug(slug) ? `${userId}:${slug}` : null;
17
+ // Format and ownership only: whether the slug still names a workspace the user belongs to
18
+ // needs the workspace list, so staleness is healed on the client.
19
+ export const parseLastWorkspaceCookie = (raw, userId) => {
20
+ if (!raw || !userId)
21
+ return null;
22
+ // A userId never contains `:`, so the first one is the separator.
23
+ const separator = raw.indexOf(':');
24
+ if (separator <= 0)
25
+ return null;
26
+ if (raw.slice(0, separator) !== userId)
27
+ return null;
28
+ const slug = raw.slice(separator + 1);
29
+ return isValidSlug(slug) ? slug : null;
30
+ };
@@ -0,0 +1,13 @@
1
+ export declare const appLocales: readonly ["ar", "bg-BG", "de-DE", "en-US", "es-ES", "fr-FR", "ja-JP", "ko-KR", "pt-BR", "ru-RU", "tr-TR", "zh-CN", "zh-TW", "vi-VN", "fa-IR", "it-IT", "pl-PL", "nl-NL"];
2
+ export type AppLocale = (typeof appLocales)[number];
3
+ export declare const DEFAULT_APP_LOCALE = "en-US";
4
+ export declare const LOCALE_QUERY_PARAM = "hl";
5
+ export declare const LOCALE_COOKIE = "LOBE_LOCALE";
6
+ export interface LocaleVocabulary<T extends string> {
7
+ fallback: T;
8
+ tags: readonly T[];
9
+ }
10
+ export declare function matchLocaleTag(tag: string | undefined): AppLocale | undefined;
11
+ export declare function matchLocaleTag<T extends string>(tag: string | undefined, tags: readonly T[]): T | undefined;
12
+ export declare function resolveRequestLocale(request: Request): AppLocale;
13
+ export declare function resolveRequestLocale<T extends string>(request: Request, vocabulary: LocaleVocabulary<T>): T;
@@ -0,0 +1,97 @@
1
+ export const appLocales = [
2
+ 'ar',
3
+ 'bg-BG',
4
+ 'de-DE',
5
+ 'en-US',
6
+ 'es-ES',
7
+ 'fr-FR',
8
+ 'ja-JP',
9
+ 'ko-KR',
10
+ 'pt-BR',
11
+ 'ru-RU',
12
+ 'tr-TR',
13
+ 'zh-CN',
14
+ 'zh-TW',
15
+ 'vi-VN',
16
+ 'fa-IR',
17
+ 'it-IT',
18
+ 'pl-PL',
19
+ 'nl-NL',
20
+ ];
21
+ export const DEFAULT_APP_LOCALE = 'en-US';
22
+ export const LOCALE_QUERY_PARAM = 'hl';
23
+ export const LOCALE_COOKIE = 'LOBE_LOCALE';
24
+ const AUTO_LOCALE = 'auto';
25
+ const APP_VOCABULARY = { fallback: DEFAULT_APP_LOCALE, tags: appLocales };
26
+ const decodeCookieValue = (value) => {
27
+ try {
28
+ return decodeURIComponent(value);
29
+ }
30
+ catch {
31
+ return value;
32
+ }
33
+ };
34
+ const readCookie = (header, name) => {
35
+ if (!header)
36
+ return undefined;
37
+ for (const part of header.split(';')) {
38
+ const separator = part.indexOf('=');
39
+ if (separator === -1 || part.slice(0, separator).trim() !== name)
40
+ continue;
41
+ return decodeCookieValue(part.slice(separator + 1).trim());
42
+ }
43
+ return undefined;
44
+ };
45
+ // Accept-Language is case-insensitive but the vocabulary is canonical BCP 47: without this,
46
+ // `zh-cn` misses `zh-CN` and silently degrades to the fallback.
47
+ const canonicalizeTag = (tag) => {
48
+ const [language = '', ...rest] = tag.split('-');
49
+ return [
50
+ language.toLowerCase(),
51
+ ...rest.map((part) => (part.length === 2 ? part.toUpperCase() : part)),
52
+ ].join('-');
53
+ };
54
+ // Aliases resolve to a prefix rather than a fixed tag, so a vocabulary without the aliased
55
+ // language simply finds nothing.
56
+ const aliasPrefix = (lowerTag) => {
57
+ if (lowerTag.startsWith('ar'))
58
+ return 'ar';
59
+ if (lowerTag.startsWith('fa'))
60
+ return 'fa';
61
+ if (lowerTag.startsWith('cn'))
62
+ return 'zh-CN';
63
+ if (lowerTag.startsWith('zh-hans'))
64
+ return 'zh-CN';
65
+ if (lowerTag.startsWith('zh-hant'))
66
+ return 'zh-TW';
67
+ return undefined;
68
+ };
69
+ export function matchLocaleTag(tag, tags = appLocales) {
70
+ if (!tag)
71
+ return undefined;
72
+ const candidate = aliasPrefix(tag.toLowerCase()) ?? canonicalizeTag(tag);
73
+ return tags.find((supported) => supported.startsWith(candidate));
74
+ }
75
+ const parseAcceptLanguage = (header) => (header ?? '')
76
+ .split(',')
77
+ .map((part) => {
78
+ const [tag = '', ...params] = part.trim().split(';');
79
+ const quality = params.map((param) => param.trim()).find((param) => param.startsWith('q='));
80
+ const weight = quality ? Number(quality.slice(2)) : 1;
81
+ return { tag: tag.trim(), weight: Number.isFinite(weight) ? weight : 0 };
82
+ })
83
+ .filter((candidate) => candidate.tag.length > 0 && candidate.tag !== '*')
84
+ .sort((a, b) => b.weight - a.weight)
85
+ .map((candidate) => candidate.tag);
86
+ export function resolveRequestLocale(request, { fallback, tags } = APP_VOCABULARY) {
87
+ const explicit = new URL(request.url).searchParams.get(LOCALE_QUERY_PARAM) ||
88
+ readCookie(request.headers.get('cookie'), LOCALE_COOKIE);
89
+ if (explicit && explicit !== AUTO_LOCALE)
90
+ return matchLocaleTag(explicit, tags) ?? fallback;
91
+ for (const tag of parseAcceptLanguage(request.headers.get('accept-language'))) {
92
+ const matched = matchLocaleTag(tag, tags);
93
+ if (matched)
94
+ return matched;
95
+ }
96
+ return fallback;
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/edge-contract",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "One implementation of the edge logic shared by the LobeHub gateway, landing, cloud and lobe-chat, plus golden fixtures every copy is tested against.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -18,6 +18,18 @@
18
18
  "types": "./dist/seo-topics.d.ts",
19
19
  "default": "./dist/seo-topics.js"
20
20
  },
21
+ "./request-locale": {
22
+ "types": "./dist/request-locale.d.ts",
23
+ "default": "./dist/request-locale.js"
24
+ },
25
+ "./last-workspace-cookie": {
26
+ "types": "./dist/last-workspace-cookie.d.ts",
27
+ "default": "./dist/last-workspace-cookie.js"
28
+ },
29
+ "./acquisition-context": {
30
+ "types": "./dist/acquisition-context.d.ts",
31
+ "default": "./dist/acquisition-context.js"
32
+ },
21
33
  "./fixtures/*": "./fixtures/*"
22
34
  },
23
35
  "files": [
@@ -35,7 +47,6 @@
35
47
  "scripts": {
36
48
  "build": "tsc -p tsconfig.build.json",
37
49
  "test": "vitest run",
38
- "typecheck": "tsc --noEmit",
39
- "generate:fixtures": "bun scripts/generate-fixtures.ts"
50
+ "typecheck": "tsc --noEmit"
40
51
  }
41
52
  }