@volter/twin-xidentity 0.1.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 +112 -0
- package/client/xidentity-consent.css +204 -0
- package/client/xidentity-consent.tsx +162 -0
- package/dist/client/xidentity-consent.bundle.js +235 -0
- package/dist/client/xidentity-consent.css +204 -0
- package/dist/client/xidentity-consent.d.ts +53 -0
- package/dist/client/xidentity-consent.js +57 -0
- package/dist/client/xidentity-consent.tsx +162 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +44 -0
- package/dist/src/index.d.ts +15 -0
- package/dist/src/index.js +105 -0
- package/dist/src/xidentity-budget.d.ts +50 -0
- package/dist/src/xidentity-budget.js +108 -0
- package/dist/src/xidentity-capabilities.d.ts +3 -0
- package/dist/src/xidentity-capabilities.js +905 -0
- package/dist/src/xidentity-conformance.d.ts +10 -0
- package/dist/src/xidentity-conformance.js +332 -0
- package/dist/src/xidentity-connector.d.ts +84 -0
- package/dist/src/xidentity-connector.js +239 -0
- package/dist/src/xidentity-consent-client.gen.d.ts +2 -0
- package/dist/src/xidentity-consent-client.gen.js +10 -0
- package/dist/src/xidentity-consent-ui.d.ts +21 -0
- package/dist/src/xidentity-consent-ui.js +94 -0
- package/dist/src/xidentity-pkce.d.ts +7 -0
- package/dist/src/xidentity-pkce.js +27 -0
- package/dist/src/xidentity-problems.d.ts +38 -0
- package/dist/src/xidentity-problems.js +108 -0
- package/dist/src/xidentity-scopes.d.ts +23 -0
- package/dist/src/xidentity-scopes.js +81 -0
- package/dist/src/xidentity-server.d.ts +33 -0
- package/dist/src/xidentity-server.js +85 -0
- package/dist/src/xidentity-store.d.ts +97 -0
- package/dist/src/xidentity-store.js +358 -0
- package/dist/src/xidentity-twin.d.ts +54 -0
- package/dist/src/xidentity-twin.js +851 -0
- package/package.json +74 -0
- package/src/cli.ts +43 -0
- package/src/index.ts +177 -0
- package/src/xidentity-budget.ts +135 -0
- package/src/xidentity-capabilities.ts +1012 -0
- package/src/xidentity-conformance.ts +370 -0
- package/src/xidentity-connector.ts +269 -0
- package/src/xidentity-consent-client.gen.ts +10 -0
- package/src/xidentity-consent-ui.ts +113 -0
- package/src/xidentity-journey.uitest.ts +277 -0
- package/src/xidentity-pkce.ts +29 -0
- package/src/xidentity-problems.ts +128 -0
- package/src/xidentity-scopes.ts +96 -0
- package/src/xidentity-server.ts +97 -0
- package/src/xidentity-store.ts +419 -0
- package/src/xidentity-twin.ts +944 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// X identity twin HTTP server — one server for the WHOLE vendor surface, because sign-in-with-X is
|
|
2
|
+
// one product spread over two host families. Point `x.com`, `twitter.com`, `api.x.com` and
|
|
3
|
+
// `api.twitter.com` here (the injector's `xidentity` VENDOR_HOSTS entry does exactly that) and an
|
|
4
|
+
// unmodified X client completes a full authorization-code round trip against it.
|
|
5
|
+
//
|
|
6
|
+
// Two things this server does that a JSON-only twin server does not:
|
|
7
|
+
// • it can answer with HTML and with 302 redirects — the authorize screen and the callback
|
|
8
|
+
// bounce are the protocol, not decoration, so the handler's `headers` (content-type, location)
|
|
9
|
+
// pass straight through rather than being flattened into a JSON envelope;
|
|
10
|
+
// • it serves the authorize page's own assets under the twin-namespaced `/_twin/assets/*`.
|
|
11
|
+
//
|
|
12
|
+
// It also tells the handler its OWN origin, so the consent form's action points BACK AT THE TWIN.
|
|
13
|
+
import { serveHttp } from '@volter/world-core';
|
|
14
|
+
import { CONSENT_CLIENT_CSS, CONSENT_CLIENT_JS, CONSENT_SCRIPT_PATH, CONSENT_STYLE_PATH } from './xidentity-consent-ui.ts';
|
|
15
|
+
import { worldNow, statefulTwinManifest, twinPublicBase } from '@volter/world-core';
|
|
16
|
+
import { handleXIdentityTwinRequest } from './xidentity-twin.ts';
|
|
17
|
+
|
|
18
|
+
/** Options every X-identity-twin HTTP surface needs, independent of who owns the socket. */
|
|
19
|
+
export interface XIdentityTwinFetchOptions {
|
|
20
|
+
root?: string;
|
|
21
|
+
readOnly?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The pack's whole HTTP surface as a plain `fetch` — Request in, Response out, no listener.
|
|
26
|
+
*
|
|
27
|
+
* This is the composable form (runtime contract R12b): a Worker / Durable Object entry has NO
|
|
28
|
+
* loopback ports, so it must mount a pack's handler IN-PROCESS. `createXIdentityTwinServer` is
|
|
29
|
+
* nothing but `Bun.serve` wrapped around this closure, so the standalone (R1) and hosted
|
|
30
|
+
* surfaces are the SAME code — there is no second HTTP adaptation to drift.
|
|
31
|
+
*
|
|
32
|
+
* WHAT IT SERVES IS UNCHANGED (R9): GET /twin is built from constants, and the only clock on
|
|
33
|
+
* the path is `worldNow()`, the world's frozen instant. The authorize screen's own script and
|
|
34
|
+
* stylesheet routes serve COMMITTED text (`xidentity-consent-client.gen.ts`, built on the dev
|
|
35
|
+
* plane by `scripts/consent-clients.ts`) — no build and no disk read on the fetch path.
|
|
36
|
+
*/
|
|
37
|
+
export function createXIdentityTwinFetch(options: XIdentityTwinFetchOptions = {}): (request: Request) => Promise<Response> {
|
|
38
|
+
const readOnly = options.readOnly ?? false;
|
|
39
|
+
return async function xIdentityTwinFetch(request: Request): Promise<Response> {
|
|
40
|
+
const url = new URL(request.url);
|
|
41
|
+
// GET /twin — the discovery manifest (education inside the twin).
|
|
42
|
+
if (request.method === 'GET' && url.pathname.replace(/\/+$/, '') === '/twin') {
|
|
43
|
+
return Response.json(statefulTwinManifest({ vendor: 'xidentity', twinOf: 'X (Twitter) identity / OAuth 2.0 + PKCE', stores: 'authorization codes, access/refresh tokens and the authorize-screen round trip' }));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (request.method === 'GET' && url.pathname === CONSENT_SCRIPT_PATH) {
|
|
47
|
+
return new Response(CONSENT_CLIENT_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8' } });
|
|
48
|
+
}
|
|
49
|
+
if (request.method === 'GET' && url.pathname === CONSENT_STYLE_PATH) {
|
|
50
|
+
return new Response(CONSENT_CLIENT_CSS, { headers: { 'content-type': 'text/css; charset=utf-8' } });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const body = request.method === 'GET' || request.method === 'HEAD' ? '' : await request.text();
|
|
54
|
+
const headers: Record<string, string> = {};
|
|
55
|
+
request.headers.forEach((value, key) => {
|
|
56
|
+
headers[key.toLowerCase()] = value;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const res = await handleXIdentityTwinRequest({
|
|
60
|
+
method: request.method,
|
|
61
|
+
path: url.pathname + (url.search || ''),
|
|
62
|
+
body,
|
|
63
|
+
headers,
|
|
64
|
+
readOnly,
|
|
65
|
+
occurredAt: worldNow(),
|
|
66
|
+
origin: twinPublicBase(request),
|
|
67
|
+
callbackOrigin: url.origin,
|
|
68
|
+
...(options.root !== undefined ? { root: options.root } : {}),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const out = { ...(res.headers ?? {}) };
|
|
72
|
+
// A string body is already rendered (HTML, or the empty body of a 302); anything else is the
|
|
73
|
+
// vendor's JSON.
|
|
74
|
+
if (typeof res.body === 'string') {
|
|
75
|
+
if (!out['content-type'] && res.body) out['content-type'] = 'text/html; charset=utf-8';
|
|
76
|
+
return new Response(res.body, { status: res.status, headers: out });
|
|
77
|
+
}
|
|
78
|
+
out['content-type'] = out['content-type'] ?? 'application/json; charset=utf-8';
|
|
79
|
+
return new Response(JSON.stringify(res.body), { status: res.status, headers: out });
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function createXIdentityTwinServer(options: { root?: string; port?: number; readOnly?: boolean }): Promise<{ port: number; stop: () => void }> {
|
|
84
|
+
const server = await serveHttp({
|
|
85
|
+
port: options.port ?? 0,
|
|
86
|
+
idleTimeout: 60,
|
|
87
|
+
fetch: createXIdentityTwinFetch(options),
|
|
88
|
+
});
|
|
89
|
+
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The authorize screen is served BY THE TWIN, at the vendor's own path — there is no second
|
|
94
|
+
* "mirror" server to start. This alias exists so the `world-xidentity mirror` command and the
|
|
95
|
+
* journey harness have the conventional entry point, and it returns the very same server.
|
|
96
|
+
*/
|
|
97
|
+
export const createXIdentityConsentServer = createXIdentityTwinServer;
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
// X identity twin — STATE. Everything the twin knows lives in the shared kernel action log (D1):
|
|
2
|
+
// OAuth clients, X accounts, the browser session (which account is "logged in" at x.com), pending
|
|
3
|
+
// authorization requests, authorization codes, access/refresh tokens, the per-(client, user) grant
|
|
4
|
+
// record and the per-user rate window. There is no parallel store.
|
|
5
|
+
//
|
|
6
|
+
// ID MINTING (ADDING_A_TWIN.md §5): every id is either the vendor's own natural key (an account
|
|
7
|
+
// id) or MINTED HERE. The minted ones used to be ENTROPIC (`randomUUID`) — never a module counter
|
|
8
|
+
// and never a row count. Every one of them is also SERVED: the `ar_` authorize handle is rendered
|
|
9
|
+
// into the screen, the `…:ac:1` code rides the callback redirect, the `…:at:1`/`…:rt:1` tokens
|
|
10
|
+
// ride the /2/oauth2/token reply, a minted client id rides the twin door's reply. Runtime contract
|
|
11
|
+
// R9 (a served response is a pure function of (request, stored state) — no clock and no randomness
|
|
12
|
+
// in served content) therefore governs all of them, and entropy is exactly the defect it names, so
|
|
13
|
+
// they are ALL minted the resend way now: a stable hash of the world instant + the number of rows
|
|
14
|
+
// of that type the store already holds (soft-deleted rows counted, so a value can never recur) +
|
|
15
|
+
// the subject it is issued for.
|
|
16
|
+
//
|
|
17
|
+
// §5's count-mint hazard — a pulled vendor id sitting in a GAP above the row count, which local
|
|
18
|
+
// creates then walk into and clobber — cannot arise here: these types are twin-internal or
|
|
19
|
+
// twin-issued, no connector pulls one, and the count is a hash SEED rather than the id itself.
|
|
20
|
+
// What §5's entropy bought (two writes at one instant never colliding) the count buys instead: it
|
|
21
|
+
// advances on every mint.
|
|
22
|
+
//
|
|
23
|
+
// The `ar_` handle carries one extra rule the credentials do not: an authorize request whose
|
|
24
|
+
// parameters already name an UNSETTLED pending row reuses that row's id, so re-serving the same
|
|
25
|
+
// authorize screen is byte-identical (and a reload does not pile up a second pending request).
|
|
26
|
+
//
|
|
27
|
+
// The mutable-in-place rows (a code being consumed, a token being revoked, a grant re-granted, a
|
|
28
|
+
// rate window advancing) carry a per-subject `rev` ordinal (the upstash precedent) so a
|
|
29
|
+
// genuine write can never be mistaken for a replay.
|
|
30
|
+
import { applyTwinWrite, applyTwinWriteAtomic, projectResources, type TwinResource } from '@volter/world-core';
|
|
31
|
+
|
|
32
|
+
export const SERVICE = 'xidentity';
|
|
33
|
+
|
|
34
|
+
export const RESOURCE_TYPES = [
|
|
35
|
+
'oauth_client',
|
|
36
|
+
'account',
|
|
37
|
+
'session',
|
|
38
|
+
'auth_request',
|
|
39
|
+
'authorization_code',
|
|
40
|
+
'access_token',
|
|
41
|
+
'refresh_token',
|
|
42
|
+
'grant',
|
|
43
|
+
'rate_window',
|
|
44
|
+
] as const;
|
|
45
|
+
export type ResourceType = (typeof RESOURCE_TYPES)[number];
|
|
46
|
+
|
|
47
|
+
export type Row = TwinResource & Record<string, any>;
|
|
48
|
+
|
|
49
|
+
export function readAll(root: string | undefined): Row[] {
|
|
50
|
+
return projectResources(SERVICE, root) as Row[];
|
|
51
|
+
}
|
|
52
|
+
export function readType(root: string | undefined, type: ResourceType): Row[] {
|
|
53
|
+
return readAll(root).filter((r) => r.type === type);
|
|
54
|
+
}
|
|
55
|
+
export function readOne(root: string | undefined, type: ResourceType, id: string): Row | undefined {
|
|
56
|
+
return readAll(root).find((r) => r.type === type && r.id === id);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The next `rev` for a subject that is being rewritten in place (survives deletes/tombstones). */
|
|
60
|
+
export function nextRev(root: string | undefined, type: ResourceType, id: string): number {
|
|
61
|
+
const existing = readOne(root, type, id);
|
|
62
|
+
return (typeof existing?.rev === 'number' ? existing.rev : 0) + 1;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The next revision from a snapshot already held under the kernel projection lock. */
|
|
66
|
+
export function nextRevIn(resources: readonly TwinResource[], type: ResourceType, id: string): number {
|
|
67
|
+
const existing = resources.find((r) => r.type === type && r.id === id) as Row | undefined;
|
|
68
|
+
return (typeof existing?.rev === 'number' ? existing.rev : 0) + 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A state-dependent one-row update whose read/revision/write decision is one kernel transaction. */
|
|
72
|
+
export async function writeAtomic(
|
|
73
|
+
type: ResourceType,
|
|
74
|
+
id: string,
|
|
75
|
+
operation: string,
|
|
76
|
+
fields: (resources: readonly TwinResource[]) => Record<string, unknown>,
|
|
77
|
+
opts: WriteOpts,
|
|
78
|
+
): Promise<Row> {
|
|
79
|
+
await applyTwinWriteAtomic(
|
|
80
|
+
SERVICE,
|
|
81
|
+
(resources) => ({
|
|
82
|
+
kind: 'write',
|
|
83
|
+
value: undefined,
|
|
84
|
+
write: {
|
|
85
|
+
operation,
|
|
86
|
+
subjectType: type,
|
|
87
|
+
subjectId: id,
|
|
88
|
+
fields: { ...fields(resources), rev: nextRevIn(resources, type, id) },
|
|
89
|
+
...(opts.occurredAt ? { occurredAt: opts.occurredAt } : {}),
|
|
90
|
+
actor: { kind: 'agent' },
|
|
91
|
+
},
|
|
92
|
+
}),
|
|
93
|
+
opts.root,
|
|
94
|
+
);
|
|
95
|
+
return readOne(opts.root, type, id)!;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type WriteOpts = { root?: string; occurredAt?: string };
|
|
99
|
+
/** Seeding also needs the origin the twin was reached on, so the demo clients' registered
|
|
100
|
+
* callbacks point back at THIS twin rather than at a baked-in port (runtime contract R7). */
|
|
101
|
+
export type SeedOpts = WriteOpts & { origin?: string };
|
|
102
|
+
|
|
103
|
+
export async function write(
|
|
104
|
+
type: ResourceType,
|
|
105
|
+
id: string,
|
|
106
|
+
operation: string,
|
|
107
|
+
fields: Record<string, unknown>,
|
|
108
|
+
opts: WriteOpts,
|
|
109
|
+
): Promise<Row> {
|
|
110
|
+
const { resource } = await applyTwinWrite(
|
|
111
|
+
SERVICE,
|
|
112
|
+
{
|
|
113
|
+
operation,
|
|
114
|
+
subjectType: type,
|
|
115
|
+
subjectId: id,
|
|
116
|
+
fields,
|
|
117
|
+
...(opts.occurredAt ? { occurredAt: opts.occurredAt } : {}),
|
|
118
|
+
actor: { kind: 'agent' },
|
|
119
|
+
},
|
|
120
|
+
opts.root,
|
|
121
|
+
);
|
|
122
|
+
return resource as Row;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── vendor-shaped id minting ────────────────────────────────────────────────────────────────────
|
|
126
|
+
// X's OAuth 2.0 artefacts are base64url blobs with an internal structure the vendor's own docs
|
|
127
|
+
// examples expose when decoded (docs.x.com user-access-token guide, fetched 2026-08-21):
|
|
128
|
+
//
|
|
129
|
+
// client_id M1M5R3BMVy13QmpScXkzTUt5OE46MTpjaQ → "<21 alnum>:1:ci"
|
|
130
|
+
// auth code VGNibzFWSWRE…RchOXlMd2dxOjE2MjIxNjA4MjU4MjU6MToxOmFjOjE
|
|
131
|
+
// → "<opaque>:<unix ms>:1:1:ac:1"
|
|
132
|
+
// access token Q0Mzb0VhZ0V5…U3NZQnZQYjoxNjIyMTQ3NzQzOTE0OjE6MTphdDox
|
|
133
|
+
// → "<opaque>:<unix ms>:1:1:at:1"
|
|
134
|
+
// refresh token bWRWa3gzdnk3…QVdxbm06MTYyMjE0Nzc0MzkxNDoxOjE6cnQ6MQ
|
|
135
|
+
// → "<opaque>:<unix ms>:1:1:rt:1"
|
|
136
|
+
//
|
|
137
|
+
// The twin reproduces the SHAPE (an integrator's code may key on it); the bytes are not the
|
|
138
|
+
// vendor's.
|
|
139
|
+
//
|
|
140
|
+
// Those bytes used to be `randomUUID()` entropy, and every one of them is SERVED (the code rides
|
|
141
|
+
// the callback redirect, the tokens ride the /2/oauth2/token reply), so R9 governs them exactly as
|
|
142
|
+
// it governs the `ar_` handle below. They are the resend pattern now: `stableHex` over (type, the
|
|
143
|
+
// world instant, the rows of that type held BEFORE the insert, the subject being issued for),
|
|
144
|
+
// occupying the same 21/45 characters of the same hex alphabet the entropic version did — the
|
|
145
|
+
// shape is byte-for-byte what it was.
|
|
146
|
+
//
|
|
147
|
+
// A twin's credentials are stand-ins, not secrets: determinism is the contract, and predictability
|
|
148
|
+
// from public state is not a threat model a twin has. UNIQUENESS is, and the seed carries it: the
|
|
149
|
+
// row COUNT advances on every mint, so two credentials of one type at one instant differ even when
|
|
150
|
+
// the subject is identical, and the SUBJECT is the credential being exchanged — the consent screen
|
|
151
|
+
// for a code, the code or spent refresh token for a token — so concurrent exchanges never meet.
|
|
152
|
+
// `xidentity.refresh.rotation` (a rotation must hand back a refresh token the caller did not send)
|
|
153
|
+
// holds by construction: the spent row is already in the store when the replacement is seeded.
|
|
154
|
+
|
|
155
|
+
const b64url = (s: string) => Buffer.from(s, 'utf8').toString('base64url');
|
|
156
|
+
/** The seed every credential is minted from. `readType` is unfiltered (soft-deletes counted), so a
|
|
157
|
+
* count — and therefore a credential — can never recur for one (type, instant, subject). */
|
|
158
|
+
function credentialSeed(root: string | undefined, type: ResourceType, instant: string | number, subject: string): string {
|
|
159
|
+
return `${type}:${instant}:${readType(root, type).length}:${subject}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** An X OAuth 2.0 client id: base64url of `<opaque>:1:ci`. */
|
|
163
|
+
export const mintClientId = (root: string | undefined, instant: string): string =>
|
|
164
|
+
b64url(`${stableHex(credentialSeed(root, 'oauth_client', instant, 'client'), 21)}:1:ci`);
|
|
165
|
+
/** An X OAuth 2.0 authorization code (`…:ac:1`). `subject` is the authorize screen it settles. */
|
|
166
|
+
export const mintAuthorizationCode = (root: string | undefined, at: number, subject: string): string =>
|
|
167
|
+
b64url(`${stableHex(credentialSeed(root, 'authorization_code', at, subject), 45)}:${at}:1:1:ac:1`);
|
|
168
|
+
/** An X OAuth 2.0 user access token (`…:at:1`). `subject` is the credential exchanged for it. */
|
|
169
|
+
export const mintAccessToken = (root: string | undefined, at: number, subject: string): string =>
|
|
170
|
+
b64url(`${stableHex(credentialSeed(root, 'access_token', at, subject), 45)}:${at}:1:1:at:1`);
|
|
171
|
+
/** An X OAuth 2.0 refresh token (`…:rt:1`). `subject` is the credential exchanged for it. */
|
|
172
|
+
export const mintRefreshToken = (root: string | undefined, at: number, subject: string): string =>
|
|
173
|
+
b64url(`${stableHex(credentialSeed(root, 'refresh_token', at, subject), 45)}:${at}:1:1:rt:1`);
|
|
174
|
+
// ── the authorize screen's handle: DETERMINISTIC, because it is SERVED (R9) ─────────────────────
|
|
175
|
+
/** FNV-1a over the seed, widened by re-hashing with a round counter until `n` hex chars exist
|
|
176
|
+
* (the resend exemplar's `stableHex`). Pure: no clock, no entropy, no host state. */
|
|
177
|
+
function stableHex(seed: string, n: number): string {
|
|
178
|
+
let out = '';
|
|
179
|
+
for (let round = 0; out.length < n; round += 1) {
|
|
180
|
+
let h = 0x811c9dc5;
|
|
181
|
+
const s = `${round}:${seed}`;
|
|
182
|
+
for (let i = 0; i < s.length; i += 1) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; }
|
|
183
|
+
out += h.toString(16).padStart(8, '0');
|
|
184
|
+
}
|
|
185
|
+
return out.slice(0, n);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Every authorize parameter an `auth_request` row holds — its stable identity. Read off the
|
|
189
|
+
* fields being written OR off a projected row (the key set is the same on both). */
|
|
190
|
+
const AUTH_REQUEST_SIG_KEYS = [
|
|
191
|
+
'clientId', 'redirectUri', 'scope', 'state', 'codeChallenge', 'codeChallengeMethod', 'accountId',
|
|
192
|
+
] as const;
|
|
193
|
+
function authRequestSignature(fields: Record<string, unknown>): string {
|
|
194
|
+
return AUTH_REQUEST_SIG_KEYS.map((k) => `${k}=${String(fields[k] ?? '')}`).join('\u0001');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Twin-internal handle for an authorize screen in flight (never leaves the twin's own pages) —
|
|
199
|
+
* `ar_` + 32 hex, the shape `randomUUID()` gave it, now a pure function of (request, stored state).
|
|
200
|
+
*
|
|
201
|
+
* An authorize request whose parameters ALREADY name an unsettled pending row reuses that row's
|
|
202
|
+
* id: pressing reload on the authorize screen is the same screen, not a new one. Otherwise the id
|
|
203
|
+
* is `stableHex(auth_request:<world instant>:<rows already held>)` — two authorize requests at one
|
|
204
|
+
* instant differ by the count, and two identical worlds mint identical ids.
|
|
205
|
+
*/
|
|
206
|
+
export function authRequestIdFor(root: string | undefined, occurredAt: string, fields: Record<string, unknown>): string {
|
|
207
|
+
const prior = readType(root, 'auth_request');
|
|
208
|
+
const signature = authRequestSignature(fields);
|
|
209
|
+
const reusable = prior.find((r) => r.settled !== true && authRequestSignature(r) === signature);
|
|
210
|
+
if (reusable) return String(reusable.id);
|
|
211
|
+
return `ar_${stableHex(`auth_request:${occurredAt}:${prior.length}`, 32)}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── seeded world ────────────────────────────────────────────────────────────────────────────────
|
|
215
|
+
// The authorize page needs SOMEBODY to be signed in at x.com. X shows the consent screen for the
|
|
216
|
+
// browser's current session; the twin's equivalent is deterministic personas seeded on first use
|
|
217
|
+
// plus a `session` row naming the active one (`POST /_twin/session` switches it — X's real switcher
|
|
218
|
+
// is the x.com account menu, which is not OAuth surface). The default clients mirror the two shapes
|
|
219
|
+
// the X developer portal hands out: a CONFIDENTIAL client (Web App — has a secret, authenticates
|
|
220
|
+
// with HTTP Basic) and a PUBLIC client (Native/SPA — no secret, client_id travels in the body).
|
|
221
|
+
|
|
222
|
+
export const DEFAULT_CLIENT_ID = 'VHdpbkRlbW9BcHAwMDAwMDAwMDA6MTpjaQ';
|
|
223
|
+
export const DEFAULT_CLIENT_SECRET = 'twin-confidential-secret-0000000000000000000000000';
|
|
224
|
+
export const DEFAULT_PUBLIC_CLIENT_ID = 'VHdpblB1YmxpY0FwcDAwMDAwMDA6MTpjaQ';
|
|
225
|
+
|
|
226
|
+
export type SeedAccount = {
|
|
227
|
+
id: string;
|
|
228
|
+
username: string;
|
|
229
|
+
name: string;
|
|
230
|
+
createdAt: string;
|
|
231
|
+
description: string;
|
|
232
|
+
location?: string;
|
|
233
|
+
profileImageUrl?: string;
|
|
234
|
+
protectedAccount: boolean;
|
|
235
|
+
verified: boolean;
|
|
236
|
+
verifiedType: 'none' | 'blue' | 'business' | 'government';
|
|
237
|
+
publicMetrics: {
|
|
238
|
+
followers_count: number;
|
|
239
|
+
following_count: number;
|
|
240
|
+
tweet_count: number;
|
|
241
|
+
listed_count: number;
|
|
242
|
+
like_count: number;
|
|
243
|
+
media_count: number;
|
|
244
|
+
};
|
|
245
|
+
url?: string;
|
|
246
|
+
confirmedEmail?: string;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/** X user ids are stable numeric strings (the docs' own example user is "2244994945"). The seeded
|
|
250
|
+
* ids start at 9e18 — vendor-SHAPED (int64-range numeric) but far ABOVE the live snowflake id
|
|
251
|
+
* space (~1.9e18 in 2026), so a locally seeded persona can never collide with a pulled real id
|
|
252
|
+
* (the datadog EVENT_ID_BASE precedent; §9 round one). */
|
|
253
|
+
export const DEFAULT_ACCOUNTS: SeedAccount[] = [
|
|
254
|
+
{
|
|
255
|
+
id: '9000000000000000001',
|
|
256
|
+
username: 'ada_twin',
|
|
257
|
+
name: 'Ada Lovelace',
|
|
258
|
+
createdAt: '2013-12-14T04:35:55.000Z',
|
|
259
|
+
description: 'Analyst. Engine programmer. First of her kind.',
|
|
260
|
+
location: 'London',
|
|
261
|
+
protectedAccount: false,
|
|
262
|
+
verified: true,
|
|
263
|
+
verifiedType: 'blue',
|
|
264
|
+
publicMetrics: { followers_count: 5834, following_count: 204, tweet_count: 1405, listed_count: 16, like_count: 320, media_count: 12 },
|
|
265
|
+
url: 'https://ada.example',
|
|
266
|
+
confirmedEmail: 'ada@twin.example',
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
id: '9000000000000000002',
|
|
270
|
+
username: 'grace_twin',
|
|
271
|
+
name: 'Grace Hopper',
|
|
272
|
+
createdAt: '2015-06-01T12:00:00.000Z',
|
|
273
|
+
description: 'It is easier to ask forgiveness than permission.',
|
|
274
|
+
protectedAccount: true,
|
|
275
|
+
verified: false,
|
|
276
|
+
verifiedType: 'none',
|
|
277
|
+
publicMetrics: { followers_count: 120, following_count: 88, tweet_count: 5210, listed_count: 3, like_count: 990, media_count: 41 },
|
|
278
|
+
},
|
|
279
|
+
];
|
|
280
|
+
|
|
281
|
+
/** NextAuth's callback path for the X provider — the one every X integration guide walks through. */
|
|
282
|
+
const CALLBACK_PATH = '/api/auth/callback/x';
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Every spelling of the ORIGIN this twin was reached on that a browser could hand back.
|
|
286
|
+
*
|
|
287
|
+
* X matches redirect URIs EXACTLY and documents NO loopback-port exception (see
|
|
288
|
+
* `redirectUriAllowed` below), so `localhost` and the loopback IP are two different
|
|
289
|
+
* registrations — while a local app is reachable as both on the same port. A demo registration
|
|
290
|
+
* naming only one of them would fail half the time for a reason the developer cannot see.
|
|
291
|
+
*/
|
|
292
|
+
function originSpellings(origin?: string): string[] {
|
|
293
|
+
const base = (origin ?? '').trim().replace(/\/+$/, '');
|
|
294
|
+
if (!base) return [];
|
|
295
|
+
let u: URL;
|
|
296
|
+
try { u = new URL(base); } catch { return [base]; }
|
|
297
|
+
if (u.protocol !== 'http:') return [base];
|
|
298
|
+
const port = u.port ? `:${u.port}` : '';
|
|
299
|
+
if (u.hostname === 'localhost') return [base, `http://127.0.0.1${port}`];
|
|
300
|
+
if (u.hostname === '127.0.0.1') return [base, `http://localhost${port}`];
|
|
301
|
+
return [base];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The redirect URIs the seeded demo clients register.
|
|
306
|
+
*
|
|
307
|
+
* Runtime contract R7: a twin NEVER bakes a port into served content or config. The callbacks are
|
|
308
|
+
* DERIVED from the origin the twin was actually reached on (`XIdentityRequest.origin`, which
|
|
309
|
+
* xidentity-server fills from the URL the request arrived at). A caller that declares no origin —
|
|
310
|
+
* an in-process call — seeds no callbacks at all and registers its own through the twin door.
|
|
311
|
+
*/
|
|
312
|
+
export function defaultRedirectUris(origin?: string): string[] {
|
|
313
|
+
return originSpellings(origin).map((base) => `${base}${CALLBACK_PATH}`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Materialise the default clients + accounts + session once per root. Idempotent by subject id:
|
|
318
|
+
* re-running it over a seeded root writes nothing new, and it NEVER overwrites an
|
|
319
|
+
* operator-registered client, account or session choice.
|
|
320
|
+
*/
|
|
321
|
+
export async function ensureSeed(opts: SeedOpts): Promise<void> {
|
|
322
|
+
await applyTwinWriteAtomic(
|
|
323
|
+
SERVICE,
|
|
324
|
+
(resources) => {
|
|
325
|
+
const has = (type: ResourceType, id: string) => resources.some((r) => r.type === type && r.id === id);
|
|
326
|
+
const missing: Array<{ type: ResourceType; id: string; fields: Record<string, unknown> }> = [];
|
|
327
|
+
if (!has('oauth_client', DEFAULT_CLIENT_ID)) missing.push({
|
|
328
|
+
type: 'oauth_client',
|
|
329
|
+
id: DEFAULT_CLIENT_ID,
|
|
330
|
+
fields: {
|
|
331
|
+
name: 'Twin Demo App',
|
|
332
|
+
secret: DEFAULT_CLIENT_SECRET,
|
|
333
|
+
clientType: 'confidential',
|
|
334
|
+
redirectUris: defaultRedirectUris(opts.origin),
|
|
335
|
+
rev: 1,
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
if (!has('oauth_client', DEFAULT_PUBLIC_CLIENT_ID)) missing.push({
|
|
339
|
+
type: 'oauth_client',
|
|
340
|
+
id: DEFAULT_PUBLIC_CLIENT_ID,
|
|
341
|
+
fields: {
|
|
342
|
+
name: 'Twin Public App',
|
|
343
|
+
secret: null,
|
|
344
|
+
clientType: 'public',
|
|
345
|
+
redirectUris: defaultRedirectUris(opts.origin),
|
|
346
|
+
rev: 1,
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
for (const account of DEFAULT_ACCOUNTS) {
|
|
350
|
+
if (has('account', account.id)) continue;
|
|
351
|
+
missing.push({
|
|
352
|
+
type: 'account',
|
|
353
|
+
id: account.id,
|
|
354
|
+
fields: {
|
|
355
|
+
username: account.username,
|
|
356
|
+
name: account.name,
|
|
357
|
+
createdAt: account.createdAt,
|
|
358
|
+
description: account.description,
|
|
359
|
+
...(account.location ? { location: account.location } : {}),
|
|
360
|
+
...(account.profileImageUrl ? { profileImageUrl: account.profileImageUrl } : {}),
|
|
361
|
+
protectedAccount: account.protectedAccount,
|
|
362
|
+
verified: account.verified,
|
|
363
|
+
verifiedType: account.verifiedType,
|
|
364
|
+
publicMetrics: account.publicMetrics,
|
|
365
|
+
...(account.url ? { url: account.url } : {}),
|
|
366
|
+
...(account.confirmedEmail ? { confirmedEmail: account.confirmedEmail } : {}),
|
|
367
|
+
rev: 1,
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
if (!has('session', 'current')) missing.push({
|
|
372
|
+
type: 'session',
|
|
373
|
+
id: 'current',
|
|
374
|
+
fields: { accountId: DEFAULT_ACCOUNTS[0]!.id, rev: 1 },
|
|
375
|
+
});
|
|
376
|
+
if (missing.length === 0) return { kind: 'skip', value: undefined };
|
|
377
|
+
const [primary, ...rest] = missing;
|
|
378
|
+
return {
|
|
379
|
+
kind: 'write',
|
|
380
|
+
value: undefined,
|
|
381
|
+
write: {
|
|
382
|
+
operation: 'seed.ensure',
|
|
383
|
+
subjectType: primary!.type,
|
|
384
|
+
subjectId: primary!.id,
|
|
385
|
+
fields: primary!.fields,
|
|
386
|
+
projection: {
|
|
387
|
+
creates: rest.map((row) => ({ type: row.type, id: row.id, fields: row.fields })),
|
|
388
|
+
},
|
|
389
|
+
...(opts.occurredAt ? { occurredAt: opts.occurredAt } : {}),
|
|
390
|
+
actor: { kind: 'system' },
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
},
|
|
394
|
+
opts.root,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** The account the x.com browser session is signed in as (the one the authorize screen consents). */
|
|
399
|
+
export function sessionAccount(root: string | undefined): Row | undefined {
|
|
400
|
+
const session = readOne(root, 'session', 'current');
|
|
401
|
+
const id = typeof session?.accountId === 'string' ? session.accountId : undefined;
|
|
402
|
+
return id ? readOne(root, 'account', id) : undefined;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Is `candidate` an EXACT registered callback URI for this client? X documents exact-match
|
|
407
|
+
* validation for OAuth 2.0 callback URLs ("This value must correspond to one of the Callback URLs
|
|
408
|
+
* defined in your App's settings" — and the legacy official SDK's own docblock spells out "exact
|
|
409
|
+
* match validation"). No loopback-port exception is documented for X, so none is modelled — a
|
|
410
|
+
* loosely-matching twin would hide the single most common integration bug.
|
|
411
|
+
*/
|
|
412
|
+
export function redirectUriAllowed(client: Row, candidate: string): boolean {
|
|
413
|
+
const registered: string[] = Array.isArray(client.redirectUris) ? client.redirectUris : [];
|
|
414
|
+
// A fragment never legitimately appears in a redirect_uri (RFC 6749 §3.1.2) and exact string
|
|
415
|
+
// membership would still admit one only if it had been REGISTERED with a fragment — refuse the
|
|
416
|
+
// candidate outright before comparing.
|
|
417
|
+
if (candidate.includes('#')) return false;
|
|
418
|
+
return registered.includes(candidate);
|
|
419
|
+
}
|