@volter/twin-tiktok 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.
Files changed (76) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +310 -0
  3. package/client/tiktok-consent.tsx +154 -0
  4. package/client/tiktok-mirror.css +137 -0
  5. package/client/tiktok-mirror.tsx +492 -0
  6. package/dist/client/tiktok-consent.bundle.js +18 -0
  7. package/dist/client/tiktok-consent.d.ts +47 -0
  8. package/dist/client/tiktok-consent.js +20 -0
  9. package/dist/client/tiktok-consent.tsx +154 -0
  10. package/dist/client/tiktok-mirror.bundle.js +487 -0
  11. package/dist/client/tiktok-mirror.css +137 -0
  12. package/dist/client/tiktok-mirror.d.ts +42 -0
  13. package/dist/client/tiktok-mirror.js +315 -0
  14. package/dist/client/tiktok-mirror.tsx +492 -0
  15. package/dist/src/cli.d.ts +2 -0
  16. package/dist/src/cli.js +44 -0
  17. package/dist/src/index.d.ts +22 -0
  18. package/dist/src/index.js +167 -0
  19. package/dist/src/tiktok-blobs.d.ts +66 -0
  20. package/dist/src/tiktok-blobs.js +161 -0
  21. package/dist/src/tiktok-budget.d.ts +56 -0
  22. package/dist/src/tiktok-budget.js +136 -0
  23. package/dist/src/tiktok-capabilities.d.ts +7 -0
  24. package/dist/src/tiktok-capabilities.js +1855 -0
  25. package/dist/src/tiktok-conformance.d.ts +11 -0
  26. package/dist/src/tiktok-conformance.js +498 -0
  27. package/dist/src/tiktok-connector.d.ts +158 -0
  28. package/dist/src/tiktok-connector.js +600 -0
  29. package/dist/src/tiktok-consent-ui.d.ts +19 -0
  30. package/dist/src/tiktok-consent-ui.js +127 -0
  31. package/dist/src/tiktok-errors.d.ts +78 -0
  32. package/dist/src/tiktok-errors.js +175 -0
  33. package/dist/src/tiktok-ids.d.ts +16 -0
  34. package/dist/src/tiktok-ids.js +48 -0
  35. package/dist/src/tiktok-media.d.ts +7 -0
  36. package/dist/src/tiktok-media.js +86 -0
  37. package/dist/src/tiktok-mirror-ui.d.ts +49 -0
  38. package/dist/src/tiktok-mirror-ui.js +159 -0
  39. package/dist/src/tiktok-pkce.d.ts +25 -0
  40. package/dist/src/tiktok-pkce.js +56 -0
  41. package/dist/src/tiktok-posting.d.ts +100 -0
  42. package/dist/src/tiktok-posting.js +599 -0
  43. package/dist/src/tiktok-sample-mp4.d.ts +10 -0
  44. package/dist/src/tiktok-sample-mp4.js +55 -0
  45. package/dist/src/tiktok-scopes.d.ts +29 -0
  46. package/dist/src/tiktok-scopes.js +106 -0
  47. package/dist/src/tiktok-server.d.ts +28 -0
  48. package/dist/src/tiktok-server.js +89 -0
  49. package/dist/src/tiktok-store.d.ts +164 -0
  50. package/dist/src/tiktok-store.js +451 -0
  51. package/dist/src/tiktok-twin.d.ts +70 -0
  52. package/dist/src/tiktok-twin.js +1197 -0
  53. package/dist/src/tiktok-user.d.ts +28 -0
  54. package/dist/src/tiktok-user.js +174 -0
  55. package/package.json +74 -0
  56. package/src/cli.ts +43 -0
  57. package/src/index.ts +270 -0
  58. package/src/tiktok-blobs.ts +217 -0
  59. package/src/tiktok-budget.ts +163 -0
  60. package/src/tiktok-capabilities.ts +2022 -0
  61. package/src/tiktok-conformance.ts +526 -0
  62. package/src/tiktok-connector.ts +637 -0
  63. package/src/tiktok-consent-ui.ts +146 -0
  64. package/src/tiktok-errors.ts +197 -0
  65. package/src/tiktok-ids.ts +51 -0
  66. package/src/tiktok-journey.uitest.ts +305 -0
  67. package/src/tiktok-media.ts +89 -0
  68. package/src/tiktok-mirror-ui.ts +167 -0
  69. package/src/tiktok-pkce.ts +61 -0
  70. package/src/tiktok-posting.ts +617 -0
  71. package/src/tiktok-sample-mp4.ts +54 -0
  72. package/src/tiktok-scopes.ts +122 -0
  73. package/src/tiktok-server.ts +100 -0
  74. package/src/tiktok-store.ts +543 -0
  75. package/src/tiktok-twin.ts +1361 -0
  76. package/src/tiktok-user.ts +137 -0
@@ -0,0 +1,543 @@
1
+ // TikTok twin — STATE. Everything the twin knows lives in the shared kernel action log (D1): the
2
+ // developer app (`oauth_client`), the TikTok accounts it can consent as, the tiktok.com browser
3
+ // session, pending authorize requests, authorization codes, user access/refresh tokens, the
4
+ // app-only client token, the per-(app, user) grant that carries the granted scope set and the
5
+ // user's `open_id`, the videos the Display API serves, and the per-endpoint rate window. There is
6
+ // no parallel store.
7
+ //
8
+ // ID MINTING (ADDING_A_TWIN.md §5): every id is either the vendor's own natural key (a video id,
9
+ // a union id) or MINTED HERE, and every minted one is SERVED — the code rides the callback
10
+ // redirect, the `act.`/`rft.`/`clt.` tokens ride the token reply, the `ar_` handle is rendered
11
+ // into the authorize screen. Runtime contract R9 therefore governs all of them: each is a stable
12
+ // hash of (the world instant + the number of rows of that type the store already holds, tombstones
13
+ // counted + the subject it is issued for), never entropy and never a bare counter.
14
+ //
15
+ // ONE MINT IS DELIBERATELY *NOT* SEEDED BY THE COUNT: `open_id`. TikTok's `open_id` is
16
+ // "the TikTok user's unique identifier" PER APP, while `union_id` is the same human across every
17
+ // app of one developer. So open_id must be STABLE for a (client_key, account) pair across every
18
+ // authorization that pair ever performs — it is derived from those two values alone, and the
19
+ // grant row is where it is recorded.
20
+ //
21
+ // The mutable-in-place rows (a code being consumed, a token being revoked, a grant re-granted, a
22
+ // rate window advancing) carry a per-subject `rev` ordinal (the upstash precedent) so a
23
+ // genuine write can never be mistaken for a replay.
24
+ import { applyTwinWrite, applyTwinWriteAtomic, projectResources, type TwinResource } from '@volter/world-core';
25
+ import { createHash } from 'node:crypto';
26
+ import { stableHex } from './tiktok-ids.ts';
27
+
28
+ /** A credential as the log keeps it: its SHA-256 (hex), never the credential. */
29
+ export function secretKey(value: string): string {
30
+ return createHash('sha256').update(value).digest('hex');
31
+ }
32
+
33
+ export const SERVICE = 'tiktok';
34
+
35
+ /**
36
+ * THE CREDENTIAL ROWS ARE BOOKKEEPING, AND THEY NEVER NAME THEIR CREDENTIAL. An authorization code,
37
+ * an access / refresh / client token and a rate window are the twin's own records: nothing about them
38
+ * crosses to TikTok, so they are `_`-prefixed (world-core `isTwinBookkeeping`) and never deployed or
39
+ * pushed. Each is keyed by the SHA-256 of the credential (`secretKey`), never the credential — the
40
+ * YouTube `_token` pattern — so no event log, resource record or changeset a World keeps holds a
41
+ * bearer in the clear. A presented credential is hashed and looked up.
42
+ */
43
+ export const BK_CODE = '_authorization_code';
44
+ export const BK_ACCESS = '_access_token';
45
+ export const BK_REFRESH = '_refresh_token';
46
+ export const BK_CLIENT = '_client_token';
47
+ export const BK_RATE = '_rate_window';
48
+
49
+ export const RESOURCE_TYPES = [
50
+ 'oauth_client',
51
+ 'account',
52
+ 'session',
53
+ 'auth_request',
54
+ BK_CODE,
55
+ BK_ACCESS,
56
+ BK_REFRESH,
57
+ BK_CLIENT,
58
+ 'grant',
59
+ 'video',
60
+ 'publish',
61
+ BK_RATE,
62
+ ] as const;
63
+ export type ResourceType = (typeof RESOURCE_TYPES)[number];
64
+
65
+ export type Row = TwinResource & Record<string, any>;
66
+
67
+ export function readAll(root: string | undefined): Row[] {
68
+ return projectResources(SERVICE, root) as Row[];
69
+ }
70
+ export function readType(root: string | undefined, type: ResourceType): Row[] {
71
+ return readAll(root).filter((r) => r.type === type);
72
+ }
73
+ export function readOne(root: string | undefined, type: ResourceType, id: string): Row | undefined {
74
+ return readAll(root).find((r) => r.type === type && r.id === id);
75
+ }
76
+
77
+ /** The next `rev` for a subject that is being rewritten in place (survives deletes/tombstones). */
78
+ export function nextRev(root: string | undefined, type: ResourceType, id: string): number {
79
+ const existing = readOne(root, type, id);
80
+ return (typeof existing?.rev === 'number' ? existing.rev : 0) + 1;
81
+ }
82
+
83
+ /** The next revision from a snapshot already held under the kernel projection lock. */
84
+ export function nextRevIn(resources: readonly TwinResource[], type: ResourceType, id: string): number {
85
+ const existing = resources.find((r) => r.type === type && r.id === id) as Row | undefined;
86
+ return (typeof existing?.rev === 'number' ? existing.rev : 0) + 1;
87
+ }
88
+
89
+ export type WriteOpts = { root?: string; occurredAt?: string };
90
+ /** Seeding also needs the origin the twin was reached on, so the demo app's registered callback
91
+ * points back at THIS twin rather than at a baked-in port (runtime contract R7). */
92
+ export type SeedOpts = WriteOpts & { origin?: string };
93
+
94
+ export async function write(
95
+ type: ResourceType,
96
+ id: string,
97
+ operation: string,
98
+ fields: Record<string, unknown>,
99
+ opts: WriteOpts,
100
+ ): Promise<Row> {
101
+ const { resource } = await applyTwinWrite(
102
+ SERVICE,
103
+ {
104
+ operation,
105
+ subjectType: type,
106
+ subjectId: id,
107
+ fields,
108
+ ...(opts.occurredAt ? { occurredAt: opts.occurredAt } : {}),
109
+ actor: { kind: 'agent' },
110
+ },
111
+ opts.root,
112
+ );
113
+ return resource as Row;
114
+ }
115
+
116
+ /** A state-dependent one-row update whose read/revision/write decision is one kernel transaction. */
117
+ export async function writeAtomic(
118
+ type: ResourceType,
119
+ id: string,
120
+ operation: string,
121
+ fields: (resources: readonly TwinResource[]) => Record<string, unknown>,
122
+ opts: WriteOpts,
123
+ ): Promise<Row> {
124
+ await applyTwinWriteAtomic(
125
+ SERVICE,
126
+ (resources) => ({
127
+ kind: 'write',
128
+ value: undefined,
129
+ write: {
130
+ operation,
131
+ subjectType: type,
132
+ subjectId: id,
133
+ fields: { ...fields(resources), rev: nextRevIn(resources, type, id) },
134
+ ...(opts.occurredAt ? { occurredAt: opts.occurredAt } : {}),
135
+ actor: { kind: 'agent' },
136
+ },
137
+ }),
138
+ opts.root,
139
+ );
140
+ return readOne(opts.root, type, id)!;
141
+ }
142
+
143
+ // ── vendor-shaped id minting ────────────────────────────────────────────────────────────────────
144
+
145
+ /** The seed every minted credential draws from. `readType` is unfiltered (soft-deletes counted),
146
+ * so a value can never recur for one (type, instant, subject). */
147
+ function credentialSeed(root: string | undefined, type: ResourceType, instant: string | number, subject: string): string {
148
+ return `${type}:${instant}:${readType(root, type).length}:${subject}`;
149
+ }
150
+
151
+ /**
152
+ * A TikTok user access token. The vendor's own example is `act.example12345Example12345Example`
153
+ * (User Access Token Management guide) — a dotted prefix plus an opaque tail, and integrations do
154
+ * key on the prefix. The twin reproduces the shape; the bytes are not the vendor's.
155
+ */
156
+ export const mintAccessToken = (root: string | undefined, at: number, subject: string): string =>
157
+ `act.${stableHex(credentialSeed(root, BK_ACCESS, at, subject), 43)}`;
158
+
159
+ /** A TikTok refresh token — the docs' `rft.example12345Example12345Example`. */
160
+ export const mintRefreshToken = (root: string | undefined, at: number, subject: string): string =>
161
+ `rft.${stableHex(credentialSeed(root, BK_REFRESH, at, subject), 43)}`;
162
+
163
+ /** An app-only client access token — the client-credentials guide's `clt.example12345…`. */
164
+ export const mintClientToken = (root: string | undefined, at: number, subject: string): string =>
165
+ `clt.${stableHex(credentialSeed(root, BK_CLIENT, at, subject), 43)}`;
166
+
167
+ /**
168
+ * An authorization code.
169
+ *
170
+ * EXTRAPOLATION, and a deliberate one. The token endpoint's parameter table says the `code` is
171
+ * "The authorization code from the web, iOS, Android or desktop authorization callback" and the
172
+ * summary page says it must be "URL decoded" — a note that is only meaningful if the code carries
173
+ * characters a query string must percent-encode. TikTok's codes are widely reported to end in a
174
+ * `*!<n>!` marker (`…%2A%211%21` on the wire), so the twin mints that shape: an integration that
175
+ * forgets to decode the callback parameter then fails HERE, locally, instead of against the real
176
+ * vendor. The exact live format is pinned by `tiktok.token.code_format` (todo).
177
+ */
178
+ export const mintAuthorizationCode = (root: string | undefined, at: number, subject: string): string =>
179
+ `${stableHex(credentialSeed(root, BK_CODE, at, subject), 40)}*!1!`;
180
+
181
+ /** Decimal digits drawn from ENTROPY. */
182
+ function randomDigits(n: number): string {
183
+ const bytes = crypto.getRandomValues(new Uint8Array(n));
184
+ return Array.from(bytes, (b) => String(b % 10)).join('');
185
+ }
186
+
187
+ // THE CONTENT POSTING IDS ARE DRAWN FROM ENTROPY, not from the instant and a count: a World cloned
188
+ // from another replays the same counts at the same instants, and a count-seeded id would hand the
189
+ // clone the ORIGIN'S publish ids, upload ids and post ids — two different posts under one id when
190
+ // the clone's changes are pushed back (LinkedIn's asset ids, the same rule). Nothing reads them back
191
+ // as replayed content: they are answered once, to the caller that made the post.
192
+
193
+ /**
194
+ * A Content Posting `publish_id`, in the two shapes the references print: `v_pub_file~v2-1.123456789`
195
+ * for a Direct Post and `v_inbox_file~v2.123456789` for an upload to the creator's inbox.
196
+ */
197
+ export const mintPublishId = (mode: 'direct' | 'inbox'): string =>
198
+ mode === 'direct' ? `v_pub_file~v2-1.${randomDigits(19)}` : `v_inbox_file~v2.${randomDigits(19)}`;
199
+
200
+ /** The upload_id in an upload_url's query (the references print `upload_id=67890`). */
201
+ export const mintUploadId = (): string => `7${randomDigits(18)}`;
202
+
203
+ /** A published post's id — the 19-digit item id the Video Object carries and status/fetch reports in
204
+ * `publicaly_available_post_id`. */
205
+ export const mintPostId = (): string => `7${randomDigits(18)}`;
206
+
207
+ /** A TikTok client key — the developer portal hands out an `aw…` string for a web app. */
208
+ export const mintClientKey = (root: string | undefined, instant: string): string =>
209
+ `aw${stableHex(credentialSeed(root, 'oauth_client', instant, 'client'), 16)}`;
210
+
211
+ /**
212
+ * Format 32 hex characters as a **version-4** UUID — the shape both `open_id` and `union_id` take
213
+ * in the vendor's own Get User Info example (`723f24d7-e717-40f8-a2b6-cb8464cd23b4`, whose 13th
214
+ * nibble is `4` and whose 17th is `a`).
215
+ *
216
+ * THE VERSION AND VARIANT NIBBLES ARE SET, deliberately: a stable hash sliced into 8-4-4-4-12 is
217
+ * UUID-SHAPED but not a valid UUID, so an integration that validates the id it stores — a Zod
218
+ * `.uuid()`, a Postgres `uuid` column, a Java `UUID.fromString` — would accept every real TikTok
219
+ * open_id and reject every one this twin minted. That is the twin being wrong where the vendor is
220
+ * right, which is exactly the class of bug a twin exists to remove. The seeded `union_id`s already
221
+ * follow this rule as literals (`00000000-0000-4000-8000-…`); this applies the same rule to the
222
+ * derived ids, and `tiktok.token.open_id_is_a_valid_uuid` pins it against the vendor's own example.
223
+ */
224
+ function asUuid(hex32: string): string {
225
+ const variant = '89ab'[parseInt(hex32[16] ?? '0', 16) % 4]!;
226
+ return `${hex32.slice(0, 8)}-${hex32.slice(8, 12)}-4${hex32.slice(13, 16)}-${variant}${hex32.slice(17, 20)}-${hex32.slice(20, 32)}`;
227
+ }
228
+
229
+ /**
230
+ * The `open_id` a given app sees a given account as — PER (client_key, account) and STABLE, which
231
+ * is the whole distinction the vendor draws between `open_id` ("The TikTok user's unique
232
+ * identifier", app-scoped) and `union_id` (the same human across one developer's apps). No count
233
+ * and no instant enter this seed: re-authorizing must hand the app back the SAME open_id, and two
234
+ * different apps must see two different ones.
235
+ */
236
+ export const openIdFor = (clientKey: string, accountId: string): string =>
237
+ asUuid(stableHex(`open_id:${clientKey}:${accountId}`, 32));
238
+
239
+ // ── the authorize screen's handle: DETERMINISTIC, because it is SERVED (R9) ─────────────────────
240
+
241
+ /** Every authorize parameter an `auth_request` row holds — its stable identity. */
242
+ const AUTH_REQUEST_SIG_KEYS = [
243
+ 'clientKey', 'redirectUri', 'scope', 'state', 'codeChallenge', 'codeChallengeMethod', 'accountId',
244
+ ] as const;
245
+ function authRequestSignature(fields: Record<string, unknown>): string {
246
+ return AUTH_REQUEST_SIG_KEYS.map((k) => `${k}=${String(fields[k] ?? '')}`).join('');
247
+ }
248
+
249
+ /**
250
+ * Twin-internal handle for an authorize screen in flight (never leaves the twin's own pages) —
251
+ * `ar_` + 32 hex, a pure function of (request, stored state).
252
+ *
253
+ * An authorize request whose parameters ALREADY name an unsettled pending row reuses that row's
254
+ * id: pressing reload on the authorize screen is the same screen, not a new one.
255
+ */
256
+ export function authRequestIdFor(root: string | undefined, occurredAt: string, fields: Record<string, unknown>): string {
257
+ const prior = readType(root, 'auth_request');
258
+ const signature = authRequestSignature(fields);
259
+ const reusable = prior.find((r) => r.settled !== true && authRequestSignature(r) === signature);
260
+ if (reusable) return String(reusable.id);
261
+ return `ar_${stableHex(`auth_request:${occurredAt}:${prior.length}`, 32)}`;
262
+ }
263
+
264
+ // ── seeded world ────────────────────────────────────────────────────────────────────────────────
265
+ // The authorize page needs SOMEBODY to be signed in at tiktok.com. TikTok shows the authorization
266
+ // sheet for the browser's current session; the twin's equivalent is deterministic personas seeded
267
+ // on first use plus a `session` row naming the active one (`POST /_twin/session` switches it —
268
+ // TikTok's real switcher is the tiktok.com account menu, which is not OAuth surface).
269
+
270
+ /** The seeded demo app's client key. TikTok web apps are issued an `aw…` key by the developer
271
+ * portal; a world running a real integration registers ITS OWN key through `POST /_twin/clients`
272
+ * (there is no TikTok API that creates an app, so this is the only honest door). */
273
+ export const DEFAULT_CLIENT_KEY = 'awtwindemoapp00001';
274
+ export const DEFAULT_CLIENT_SECRET = 'twin-demo-client-secret-000000000000';
275
+
276
+ /** NextAuth's callback path for the TikTok provider — the one most Login Kit guides walk through. */
277
+ const CALLBACK_PATH = '/api/auth/callback/tiktok';
278
+
279
+ export type SeedAccount = {
280
+ /** The subject id: the account's `union_id`, which is the vendor's own cross-app user key. */
281
+ unionId: string;
282
+ username: string;
283
+ displayName: string;
284
+ avatarUrl: string;
285
+ avatarUrl100: string;
286
+ avatarLargeUrl: string;
287
+ bioDescription: string;
288
+ profileDeepLink: string;
289
+ isVerified: boolean;
290
+ followerCount: number;
291
+ followingCount: number;
292
+ likesCount: number;
293
+ videoCount: number;
294
+ };
295
+
296
+ /**
297
+ * TikTok `union_id`s are v4-shaped UUIDs. The seeded personas take the ALL-ZERO corner of that
298
+ * space (`00000000-0000-4000-8000-…`), which a real v4 generator will not produce, so a locally
299
+ * seeded persona can never collide with a pulled real account (the datadog EVENT_ID_BASE / xidentity
300
+ * 9e18 precedent: the kernel projects twin-side writes over observations, so a colliding id would
301
+ * keep serving the local row).
302
+ */
303
+ export const DEFAULT_ACCOUNTS: SeedAccount[] = [
304
+ {
305
+ unionId: '00000000-0000-4000-8000-000000000001',
306
+ username: 'ada_twin',
307
+ displayName: 'Ada Lovelace',
308
+ avatarUrl: 'https://p16-sign.tiktokcdn-us.com/twin/ada~c5_168x168.jpeg',
309
+ avatarUrl100: 'https://p16-sign.tiktokcdn-us.com/twin/ada~c5_100x100.jpeg',
310
+ avatarLargeUrl: 'https://p16-sign.tiktokcdn-us.com/twin/ada~c5_1080x1080.jpeg',
311
+ bioDescription: 'Analyst. Engine programmer. First of her kind.',
312
+ profileDeepLink: 'https://www.tiktok.com/@ada_twin',
313
+ isVerified: true,
314
+ followerCount: 58340,
315
+ followingCount: 204,
316
+ likesCount: 910233,
317
+ videoCount: 2,
318
+ },
319
+ {
320
+ unionId: '00000000-0000-4000-8000-000000000002',
321
+ username: 'grace_twin',
322
+ displayName: 'Grace Hopper',
323
+ avatarUrl: 'https://p16-sign.tiktokcdn-us.com/twin/grace~c5_168x168.jpeg',
324
+ avatarUrl100: 'https://p16-sign.tiktokcdn-us.com/twin/grace~c5_100x100.jpeg',
325
+ avatarLargeUrl: 'https://p16-sign.tiktokcdn-us.com/twin/grace~c5_1080x1080.jpeg',
326
+ bioDescription: 'It is easier to ask forgiveness than permission.',
327
+ profileDeepLink: 'https://www.tiktok.com/@grace_twin',
328
+ isVerified: false,
329
+ followerCount: 1204,
330
+ followingCount: 88,
331
+ likesCount: 5210,
332
+ videoCount: 1,
333
+ },
334
+ ];
335
+
336
+ export type SeedVideo = {
337
+ id: string;
338
+ ownerUnionId: string;
339
+ title: string;
340
+ videoDescription: string;
341
+ /** UTC unix epoch SECONDS — the vendor's own `create_time` unit. */
342
+ createTime: number;
343
+ duration: number;
344
+ height: number;
345
+ width: number;
346
+ likeCount: number;
347
+ commentCount: number;
348
+ shareCount: number;
349
+ viewCount: number;
350
+ };
351
+
352
+ /** TikTok video ids ("also called item_id") are 19-digit numeric strings. The seeded ones start at
353
+ * 7.0e18, inside the shape but pinned to constants so the Display API reads are deterministic. */
354
+ export const DEFAULT_VIDEOS: SeedVideo[] = [
355
+ {
356
+ id: '7000000000000000001',
357
+ ownerUnionId: '00000000-0000-4000-8000-000000000001',
358
+ title: 'Difference engine, part 1',
359
+ videoDescription: 'How the machine carries. #engineering',
360
+ createTime: 1_700_000_400,
361
+ duration: 42,
362
+ height: 1024,
363
+ width: 576,
364
+ likeCount: 4821,
365
+ commentCount: 133,
366
+ shareCount: 77,
367
+ viewCount: 190_233,
368
+ },
369
+ {
370
+ id: '7000000000000000002',
371
+ ownerUnionId: '00000000-0000-4000-8000-000000000001',
372
+ title: 'Notes on the Analytical Engine',
373
+ videoDescription: 'Note G, read aloud. #history',
374
+ createTime: 1_700_086_800,
375
+ duration: 61,
376
+ height: 1024,
377
+ width: 576,
378
+ likeCount: 9910,
379
+ commentCount: 402,
380
+ shareCount: 311,
381
+ viewCount: 820_114,
382
+ },
383
+ {
384
+ id: '7000000000000000003',
385
+ ownerUnionId: '00000000-0000-4000-8000-000000000002',
386
+ title: 'A nanosecond of wire',
387
+ videoDescription: 'This is how far light travels. #compsci',
388
+ createTime: 1_700_173_200,
389
+ duration: 30,
390
+ height: 1024,
391
+ width: 576,
392
+ likeCount: 22_004,
393
+ commentCount: 806,
394
+ shareCount: 1204,
395
+ viewCount: 1_400_500,
396
+ },
397
+ ];
398
+
399
+ /**
400
+ * Every spelling of the ORIGIN this twin was reached on that a browser could hand back.
401
+ *
402
+ * TikTok matches a callback against the app's registered Redirect URIs ("It must match one of the
403
+ * redirect URIs you registered"), so `localhost` and the loopback IP are two different
404
+ * registrations while a local app is reachable as both on the same port. A demo registration
405
+ * naming only one of them would fail half the time for a reason the developer cannot see.
406
+ */
407
+ function originSpellings(origin?: string): string[] {
408
+ const base = (origin ?? '').trim().replace(/\/+$/, '');
409
+ if (!base) return [];
410
+ let u: URL;
411
+ try { u = new URL(base); } catch { return [base]; }
412
+ if (u.protocol !== 'http:') return [base];
413
+ const port = u.port ? `:${u.port}` : '';
414
+ if (u.hostname === 'localhost') return [base, `http://127.0.0.1${port}`];
415
+ if (u.hostname === '127.0.0.1') return [base, `http://localhost${port}`];
416
+ return [base];
417
+ }
418
+
419
+ /**
420
+ * The redirect URIs the seeded demo app registers.
421
+ *
422
+ * Runtime contract R7: a twin NEVER bakes a port into served content or config. The callbacks are
423
+ * DERIVED from the origin the twin was actually reached on (`TikTokRequest.origin`, which
424
+ * tiktok-server fills from the URL the request arrived at). A caller that declares no origin — an
425
+ * in-process call — seeds no callbacks at all and registers its own through the twin door.
426
+ */
427
+ export function defaultRedirectUris(origin?: string): string[] {
428
+ return originSpellings(origin).map((base) => `${base}${CALLBACK_PATH}`);
429
+ }
430
+
431
+ /**
432
+ * Materialise the default app + accounts + videos + session once per root. Idempotent by subject
433
+ * id: re-running it over a seeded root writes nothing new, and it NEVER overwrites an
434
+ * operator-registered app, account, video or session choice.
435
+ */
436
+ export async function ensureSeed(opts: SeedOpts): Promise<void> {
437
+ await applyTwinWriteAtomic(
438
+ SERVICE,
439
+ (resources) => {
440
+ const has = (type: ResourceType, id: string) => resources.some((r) => r.type === type && r.id === id);
441
+ const missing: Array<{ type: ResourceType; id: string; fields: Record<string, unknown> }> = [];
442
+ if (!has('oauth_client', DEFAULT_CLIENT_KEY)) missing.push({
443
+ type: 'oauth_client',
444
+ id: DEFAULT_CLIENT_KEY,
445
+ fields: {
446
+ name: 'Twin Demo App',
447
+ secretSha256: secretKey(DEFAULT_CLIENT_SECRET),
448
+ redirectUris: defaultRedirectUris(opts.origin),
449
+ rev: 1,
450
+ },
451
+ });
452
+ for (const account of DEFAULT_ACCOUNTS) {
453
+ if (has('account', account.unionId)) continue;
454
+ missing.push({
455
+ type: 'account',
456
+ id: account.unionId,
457
+ fields: {
458
+ username: account.username,
459
+ displayName: account.displayName,
460
+ avatarUrl: account.avatarUrl,
461
+ avatarUrl100: account.avatarUrl100,
462
+ avatarLargeUrl: account.avatarLargeUrl,
463
+ bioDescription: account.bioDescription,
464
+ profileDeepLink: account.profileDeepLink,
465
+ isVerified: account.isVerified,
466
+ followerCount: account.followerCount,
467
+ followingCount: account.followingCount,
468
+ likesCount: account.likesCount,
469
+ videoCount: account.videoCount,
470
+ rev: 1,
471
+ },
472
+ });
473
+ }
474
+ for (const video of DEFAULT_VIDEOS) {
475
+ if (has('video', video.id)) continue;
476
+ missing.push({
477
+ type: 'video',
478
+ id: video.id,
479
+ fields: {
480
+ ownerUnionId: video.ownerUnionId,
481
+ title: video.title,
482
+ videoDescription: video.videoDescription,
483
+ createTime: video.createTime,
484
+ duration: video.duration,
485
+ height: video.height,
486
+ width: video.width,
487
+ likeCount: video.likeCount,
488
+ commentCount: video.commentCount,
489
+ shareCount: video.shareCount,
490
+ viewCount: video.viewCount,
491
+ rev: 1,
492
+ },
493
+ });
494
+ }
495
+ if (!has('session', 'current')) missing.push({
496
+ type: 'session',
497
+ id: 'current',
498
+ fields: { accountId: DEFAULT_ACCOUNTS[0]!.unionId, rev: 1 },
499
+ });
500
+ if (missing.length === 0) return { kind: 'skip', value: undefined };
501
+ const [primary, ...rest] = missing;
502
+ return {
503
+ kind: 'write',
504
+ value: undefined,
505
+ write: {
506
+ operation: 'seed.ensure',
507
+ subjectType: primary!.type,
508
+ subjectId: primary!.id,
509
+ fields: primary!.fields,
510
+ projection: { creates: rest.map((row) => ({ type: row.type, id: row.id, fields: row.fields })) },
511
+ ...(opts.occurredAt ? { occurredAt: opts.occurredAt } : {}),
512
+ actor: { kind: 'system' },
513
+ },
514
+ };
515
+ },
516
+ opts.root,
517
+ );
518
+ }
519
+
520
+ /** The account the tiktok.com browser session is signed in as (the one the sheet consents). */
521
+ export function sessionAccount(root: string | undefined): Row | undefined {
522
+ const session = readOne(root, 'session', 'current');
523
+ const id = typeof session?.accountId === 'string' ? session.accountId : undefined;
524
+ return id ? readOne(root, 'account', id) : undefined;
525
+ }
526
+
527
+ /**
528
+ * Is `candidate` a registered Redirect URI for this app? TikTok's parameter table says the
529
+ * redirect_uri "must match one of the redirect URIs you registered", so the twin compares exactly.
530
+ *
531
+ * EVIDENCE BOUNDARY: the docs do not state whether the comparison is exact or prefix-based, nor
532
+ * whether TikTok's documented desktop-only allowance for `http://127.0.0.1:*` wildcards extends to
533
+ * web apps. Exact is the strict reading, and a loosely-matching twin would hide the single most
534
+ * common Login Kit integration bug; `tiktok.authorize.redirect_uri_matching` (todo) pins the live
535
+ * rule and `tiktok.authorize.redirect_uri_scheme_rules` pins the scheme/port restrictions.
536
+ */
537
+ export function redirectUriAllowed(client: Row, candidate: string): boolean {
538
+ const registered: string[] = Array.isArray(client.redirectUris) ? client.redirectUris : [];
539
+ // A fragment never legitimately appears in a redirect_uri (RFC 6749 §3.1.2); refuse it outright
540
+ // rather than let an exact registration smuggle one through.
541
+ if (candidate.includes('#')) return false;
542
+ return registered.includes(candidate);
543
+ }