@volter/twin-tiktok 0.1.37 → 1.0.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 (202) hide show
  1. package/README.md +25 -308
  2. package/business/spec/SOURCE.md +17 -0
  3. package/business/spec/openapi.json +1045 -0
  4. package/business/spec/sources.json +84 -0
  5. package/business/src/fetch.ts +14 -0
  6. package/business/src/generated/surface.gen.json +1 -0
  7. package/business/src/generated/ui.gen.json +1 -0
  8. package/business/src/index.ts +3 -0
  9. package/business/src/manifest.ts +38 -0
  10. package/business/src/semantics/index.ts +16 -0
  11. package/business/src/semantics/open_api.ts +142 -0
  12. package/business/src/semantics/shared.ts +119 -0
  13. package/business/src/semantics/states.ts +3 -0
  14. package/dist/business/spec/SOURCE.md +17 -0
  15. package/dist/business/spec/openapi.json +1045 -0
  16. package/dist/business/spec/sources.json +84 -0
  17. package/dist/business/src/fetch.d.ts +2 -0
  18. package/dist/business/src/fetch.js +13 -0
  19. package/dist/business/src/fetch.ts +14 -0
  20. package/dist/business/src/generated/surface.gen.json +1 -0
  21. package/dist/business/src/generated/ui.gen.json +1 -0
  22. package/dist/business/src/index.d.ts +2 -0
  23. package/dist/business/src/index.js +3 -0
  24. package/dist/business/src/index.ts +3 -0
  25. package/dist/business/src/manifest.d.ts +2 -0
  26. package/dist/business/src/manifest.js +33 -0
  27. package/dist/business/src/manifest.ts +38 -0
  28. package/dist/business/src/semantics/index.d.ts +2 -0
  29. package/dist/business/src/semantics/index.js +12 -0
  30. package/dist/business/src/semantics/index.ts +16 -0
  31. package/dist/business/src/semantics/open_api.d.ts +10 -0
  32. package/dist/business/src/semantics/open_api.js +162 -0
  33. package/dist/business/src/semantics/open_api.ts +142 -0
  34. package/dist/business/src/semantics/shared.d.ts +31 -0
  35. package/dist/business/src/semantics/shared.js +109 -0
  36. package/dist/business/src/semantics/shared.ts +119 -0
  37. package/dist/business/src/semantics/states.d.ts +8 -0
  38. package/dist/business/src/semantics/states.js +3 -0
  39. package/dist/business/src/semantics/states.ts +3 -0
  40. package/dist/generated/pack-facts.json +55 -0
  41. package/dist/journeys/coverage.json +220 -0
  42. package/dist/journeys/customer-life.json +2950 -0
  43. package/dist/journeys/customer-life.outline.md +59 -0
  44. package/dist/journeys/decisions.json +161 -0
  45. package/dist/journeys/demand.json +142 -0
  46. package/dist/journeys/score.json +294 -0
  47. package/dist/journeys/unreachable.json +13 -0
  48. package/dist/open/spec/SOURCE.md +20 -0
  49. package/dist/open/spec/openapi.json +1417 -0
  50. package/dist/open/spec/sources.json +124 -0
  51. package/dist/open/src/engine/mp4.d.ts +6 -0
  52. package/dist/open/src/engine/mp4.js +55 -0
  53. package/dist/open/src/engine/mp4.ts +48 -0
  54. package/dist/open/src/fetch.d.ts +2 -0
  55. package/dist/open/src/fetch.js +17 -0
  56. package/dist/open/src/fetch.ts +18 -0
  57. package/dist/open/src/generated/surface.gen.json +1 -0
  58. package/dist/open/src/generated/ui.gen.json +1 -0
  59. package/dist/open/src/index.d.ts +2 -0
  60. package/dist/open/src/index.js +3 -0
  61. package/dist/open/src/index.ts +3 -0
  62. package/dist/open/src/manifest.d.ts +2 -0
  63. package/dist/open/src/manifest.js +53 -0
  64. package/dist/open/src/manifest.ts +59 -0
  65. package/dist/open/src/screens/authorize.d.ts +2 -0
  66. package/dist/open/src/screens/authorize.js +53 -0
  67. package/dist/open/src/screens/authorize.ts +53 -0
  68. package/dist/open/src/semantics/doors.d.ts +10 -0
  69. package/dist/open/src/semantics/doors.js +118 -0
  70. package/dist/open/src/semantics/doors.ts +121 -0
  71. package/dist/open/src/semantics/index.d.ts +2 -0
  72. package/dist/open/src/semantics/index.js +16 -0
  73. package/dist/open/src/semantics/index.ts +20 -0
  74. package/dist/open/src/semantics/oauth.d.ts +2 -0
  75. package/dist/open/src/semantics/oauth.js +30 -0
  76. package/dist/open/src/semantics/oauth.ts +28 -0
  77. package/dist/open/src/semantics/post.d.ts +6 -0
  78. package/dist/open/src/semantics/post.js +66 -0
  79. package/dist/open/src/semantics/post.ts +60 -0
  80. package/dist/open/src/semantics/shared.d.ts +51 -0
  81. package/dist/open/src/semantics/shared.js +196 -0
  82. package/dist/open/src/semantics/shared.ts +197 -0
  83. package/dist/open/src/semantics/states.d.ts +20 -0
  84. package/dist/open/src/semantics/states.js +15 -0
  85. package/dist/open/src/semantics/states.ts +23 -0
  86. package/dist/open/src/semantics/user.d.ts +2 -0
  87. package/dist/open/src/semantics/user.js +24 -0
  88. package/dist/open/src/semantics/user.ts +24 -0
  89. package/dist/open/src/semantics/video.d.ts +4 -0
  90. package/dist/open/src/semantics/video.js +112 -0
  91. package/dist/open/src/semantics/video.ts +97 -0
  92. package/dist/src/cli.js +11 -34
  93. package/dist/src/fetch.d.ts +2 -0
  94. package/dist/src/fetch.js +9 -0
  95. package/dist/src/index.d.ts +5 -22
  96. package/dist/src/index.js +13 -167
  97. package/dist/src/manifest.d.ts +2 -0
  98. package/dist/src/manifest.js +28 -0
  99. package/dist/src/semantics/shared.d.ts +49 -0
  100. package/dist/src/semantics/shared.js +61 -0
  101. package/dist/src/server.d.ts +7 -0
  102. package/dist/src/server.js +7 -0
  103. package/generated/pack-facts.json +55 -0
  104. package/journeys/coverage.json +220 -0
  105. package/journeys/customer-life.json +2950 -0
  106. package/journeys/customer-life.outline.md +59 -0
  107. package/journeys/decisions.json +161 -0
  108. package/journeys/demand.json +142 -0
  109. package/journeys/score.json +294 -0
  110. package/journeys/unreachable.json +13 -0
  111. package/open/spec/SOURCE.md +20 -0
  112. package/open/spec/openapi.json +1417 -0
  113. package/open/spec/sources.json +124 -0
  114. package/open/src/engine/mp4.ts +48 -0
  115. package/open/src/fetch.ts +18 -0
  116. package/open/src/generated/surface.gen.json +1 -0
  117. package/open/src/generated/ui.gen.json +1 -0
  118. package/open/src/index.ts +3 -0
  119. package/open/src/manifest.ts +59 -0
  120. package/open/src/screens/authorize.ts +53 -0
  121. package/open/src/semantics/doors.ts +121 -0
  122. package/open/src/semantics/index.ts +20 -0
  123. package/open/src/semantics/oauth.ts +28 -0
  124. package/open/src/semantics/post.ts +60 -0
  125. package/open/src/semantics/shared.ts +197 -0
  126. package/open/src/semantics/states.ts +23 -0
  127. package/open/src/semantics/user.ts +24 -0
  128. package/open/src/semantics/video.ts +97 -0
  129. package/package.json +13 -41
  130. package/src/cli.ts +11 -32
  131. package/src/fetch.ts +10 -0
  132. package/src/index.ts +15 -270
  133. package/src/manifest.ts +33 -0
  134. package/src/semantics/shared.ts +78 -0
  135. package/src/server.ts +8 -0
  136. package/client/tiktok-consent.tsx +0 -154
  137. package/client/tiktok-mirror.css +0 -137
  138. package/client/tiktok-mirror.tsx +0 -497
  139. package/dist/client/tiktok-consent.bundle.js +0 -18
  140. package/dist/client/tiktok-consent.d.ts +0 -47
  141. package/dist/client/tiktok-consent.js +0 -20
  142. package/dist/client/tiktok-consent.tsx +0 -154
  143. package/dist/client/tiktok-mirror.bundle.js +0 -484
  144. package/dist/client/tiktok-mirror.css +0 -137
  145. package/dist/client/tiktok-mirror.d.ts +0 -42
  146. package/dist/client/tiktok-mirror.js +0 -325
  147. package/dist/client/tiktok-mirror.tsx +0 -497
  148. package/dist/src/tiktok-blobs.d.ts +0 -66
  149. package/dist/src/tiktok-blobs.js +0 -161
  150. package/dist/src/tiktok-budget.d.ts +0 -56
  151. package/dist/src/tiktok-budget.js +0 -136
  152. package/dist/src/tiktok-capabilities.d.ts +0 -7
  153. package/dist/src/tiktok-capabilities.js +0 -1855
  154. package/dist/src/tiktok-conformance.d.ts +0 -11
  155. package/dist/src/tiktok-conformance.js +0 -498
  156. package/dist/src/tiktok-connector.d.ts +0 -158
  157. package/dist/src/tiktok-connector.js +0 -600
  158. package/dist/src/tiktok-consent-ui.d.ts +0 -19
  159. package/dist/src/tiktok-consent-ui.js +0 -127
  160. package/dist/src/tiktok-errors.d.ts +0 -78
  161. package/dist/src/tiktok-errors.js +0 -175
  162. package/dist/src/tiktok-ids.d.ts +0 -16
  163. package/dist/src/tiktok-ids.js +0 -48
  164. package/dist/src/tiktok-media.d.ts +0 -7
  165. package/dist/src/tiktok-media.js +0 -86
  166. package/dist/src/tiktok-mirror-ui.d.ts +0 -61
  167. package/dist/src/tiktok-mirror-ui.js +0 -187
  168. package/dist/src/tiktok-pkce.d.ts +0 -25
  169. package/dist/src/tiktok-pkce.js +0 -56
  170. package/dist/src/tiktok-posting.d.ts +0 -100
  171. package/dist/src/tiktok-posting.js +0 -599
  172. package/dist/src/tiktok-sample-mp4.d.ts +0 -10
  173. package/dist/src/tiktok-sample-mp4.js +0 -55
  174. package/dist/src/tiktok-scopes.d.ts +0 -29
  175. package/dist/src/tiktok-scopes.js +0 -106
  176. package/dist/src/tiktok-server.d.ts +0 -28
  177. package/dist/src/tiktok-server.js +0 -99
  178. package/dist/src/tiktok-store.d.ts +0 -164
  179. package/dist/src/tiktok-store.js +0 -451
  180. package/dist/src/tiktok-twin.d.ts +0 -70
  181. package/dist/src/tiktok-twin.js +0 -1197
  182. package/dist/src/tiktok-user.d.ts +0 -28
  183. package/dist/src/tiktok-user.js +0 -174
  184. package/src/tiktok-blobs.ts +0 -217
  185. package/src/tiktok-budget.ts +0 -163
  186. package/src/tiktok-capabilities.ts +0 -2022
  187. package/src/tiktok-conformance.ts +0 -526
  188. package/src/tiktok-connector.ts +0 -637
  189. package/src/tiktok-consent-ui.ts +0 -146
  190. package/src/tiktok-errors.ts +0 -197
  191. package/src/tiktok-ids.ts +0 -51
  192. package/src/tiktok-journey.uitest.ts +0 -305
  193. package/src/tiktok-media.ts +0 -89
  194. package/src/tiktok-mirror-ui.ts +0 -193
  195. package/src/tiktok-pkce.ts +0 -61
  196. package/src/tiktok-posting.ts +0 -617
  197. package/src/tiktok-sample-mp4.ts +0 -54
  198. package/src/tiktok-scopes.ts +0 -122
  199. package/src/tiktok-server.ts +0 -110
  200. package/src/tiktok-store.ts +0 -543
  201. package/src/tiktok-twin.ts +0 -1361
  202. package/src/tiktok-user.ts +0 -137
@@ -0,0 +1,197 @@
1
+ import { mp4Facts } from '../engine/mp4.ts';
2
+ // What every family of the open lane shares beyond the vendor's (../../../src/semantics/shared.ts): the open platform's
3
+ // envelope, the scopes its apps are granted, and who a request is.
4
+ import type { HandlerContext } from '@volter/world-core';
5
+ import { ACCESS, ACCESS_TTL, APP, CHUNK, CODE, PERSON, PUBLISH, REFRESH, REFRESH_TTL, VIDEO, appByKey, bySha, clientSecret, credential, mediaUrl, next, openIdOf, privacyOptions, seconds, sha256, type Row, unionIdOf, videoMoment, videoPage } from '../../../src/semantics/shared.ts';
6
+
7
+ export * from '../../../src/semantics/shared.ts';
8
+ export type { HandlerContext };
9
+
10
+ /** The scopes an app on the developer site is granted: every product scope Postiz asks for (the World's app is set up
11
+ * with the products it uses). */
12
+ export const APP_SCOPES = ['user.info.basic', 'user.info.profile', 'user.info.stats', 'video.list', 'video.publish', 'video.upload'];
13
+ /** The scopes a business app on the ads portal is granted: those Postiz's TikTok Business provider asks for. */
14
+ export const BUSINESS_SCOPES = ['user.info.basic', 'user.info.username', 'user.info.stats', 'video.list', 'video.upload', 'video.publish'];
15
+
16
+ // ── the envelope ───────────────────────────────────────────────────────────────────────────────
17
+ // source: https://developers.tiktok.com/doc/content-posting-api-reference-query-creator-info "Any code other than"
18
+ /** An answer: `{data, error: {code: 'ok', message: '', log_id}}`. */
19
+ export const logId = (ctx: Pick<HandlerContext, 'crypto' | 'occurredAt'>, what: string): string =>
20
+ `${ctx.occurredAt.replace(/\D/g, '').slice(0, 14)}${ctx.crypto.digest('sha256', `${ctx.occurredAt}:${what}`, 'hex').slice(0, 20).toUpperCase()}`;
21
+ export const ok = (ctx: Pick<HandlerContext, 'crypto' | 'occurredAt'>, data: unknown): Response =>
22
+ Response.json({ data, error: { code: 'ok', message: '', log_id: logId(ctx, JSON.stringify(data)) } });
23
+ /** A refusal in the same envelope. */
24
+ export const refuse = (ctx: Pick<HandlerContext, 'crypto' | 'occurredAt'>, status: number, code: string, message: string): Response =>
25
+ Response.json({ data: {}, error: { code, message, log_id: logId(ctx, code) } }, { status });
26
+
27
+ /** Who a request is: the access token it bears, live, with its app, its person and its scopes. */
28
+ export type Caller = { token: Row; app: Row; person: Row; scopes: string[]; openId: string };
29
+ export function callerOf(ctx: HandlerContext): Caller | undefined {
30
+ const value = /^Bearer\s+(\S+)$/i.exec(ctx.call.request.headers.get('authorization') ?? '')?.[1];
31
+ const token = value ? bySha(ctx, ACCESS, value) : undefined;
32
+ if (!token || token.kind === 'business' || token.revoked === true || Number(token.expires_at) <= seconds(ctx.occurredAt)) return undefined;
33
+ const app = ctx.row(APP, String(token.app));
34
+ const person = ctx.row(PERSON, String(token.person));
35
+ if (!app || !person) return undefined;
36
+ return { token, app, person, scopes: String(token.scope).split(','), openId: openIdOf(ctx, app, person) };
37
+ }
38
+
39
+ // source: https://developers.tiktok.com/doc/content-posting-api-reference-query-creator-info "The access_token is invalid or has expired."
40
+ export const invalidToken = (ctx: HandlerContext): Response => refuse(ctx, 401, 'access_token_invalid', 'The access token is invalid or not found in the request.');
41
+ /** The caller, or the refusal: no live token (401 access_token_invalid), or none of the scopes named (401
42
+ * scope_not_authorized). */
43
+ export function authorized(ctx: HandlerContext, ...scopes: string[]): Caller | Response {
44
+ const c = callerOf(ctx);
45
+ if (!c) return invalidToken(ctx);
46
+ // source: https://developers.tiktok.com/doc/content-posting-api-reference-query-creator-info "The access_token does not bear user's grant on"
47
+ if (scopes.length && !scopes.some((s) => c.scopes.includes(s))) return refuse(ctx, 401, 'scope_not_authorized', `The user did not authorize the scope required for completing this request (${scopes.join(' or ')}).`);
48
+ return c;
49
+ }
50
+
51
+
52
+ export const oauthError = (ctx: HandlerContext, error: string, description: string): Response =>
53
+ Response.json({ error, error_description: description, log_id: logId(ctx, error) }, { status: error === 'invalid_client' ? 401 : 400 });
54
+
55
+ /** Whether a code's PKCE challenge refuses the verifier: TikTok's desktop flow sends the challenge as the verifier's
56
+ * SHA-256 in hex; a code made without one is not checked. */
57
+ export const pkceRefused = (ctx: HandlerContext, code: Row, verifier: unknown): boolean =>
58
+ typeof code.challenge === 'string' && ctx.crypto.digest('sha256', String(verifier ?? ''), 'hex') !== code.challenge;
59
+
60
+ export async function issue(ctx: HandlerContext, app: Row, person: Row, scope: string): Promise<Response> {
61
+ const now = seconds(ctx.occurredAt);
62
+ const access = await credential(ctx, ACCESS, 'act', { app: app.id, person: person.id, scope, expires_at: now + ACCESS_TTL, revoked: false });
63
+ const refresh = await credential(ctx, REFRESH, 'rft', { app: app.id, person: person.id, scope, access: access.id, expires_at: now + REFRESH_TTL, used: false });
64
+ return Response.json({
65
+ access_token: access.value, expires_in: ACCESS_TTL, open_id: openIdOf(ctx, app, person), refresh_expires_in: REFRESH_TTL,
66
+ refresh_token: refresh.value, scope, token_type: 'Bearer',
67
+ });
68
+ }
69
+
70
+ // source: https://developers.tiktok.com/doc/tiktok-api-v2-get-user-info "The set of user fields to request for"
71
+ export const FIELD_SCOPE: Record<string, string> = {
72
+ open_id: 'user.info.basic', union_id: 'user.info.basic', avatar_url: 'user.info.basic', avatar_url_100: 'user.info.basic', avatar_large_url: 'user.info.basic', display_name: 'user.info.basic',
73
+ bio_description: 'user.info.profile', profile_deep_link: 'user.info.profile', is_verified: 'user.info.profile', username: 'user.info.profile',
74
+ follower_count: 'user.info.stats', following_count: 'user.info.stats', likes_count: 'user.info.stats', video_count: 'user.info.stats',
75
+ };
76
+
77
+ export const videosObj = (v: unknown): Row => (v && typeof v === 'object' && !Array.isArray(v) ? v as Row : {});
78
+
79
+ // source: https://developers.tiktok.com/doc/tiktok-api-v2-video-query "choose from"
80
+ export const FIELDS = ['id', 'create_time', 'cover_image_url', 'share_url', 'video_description', 'duration', 'height', 'width', 'title', 'embed_html', 'embed_link', 'like_count', 'comment_count', 'share_count', 'view_count', 'is_aigc'];
81
+
82
+ export function fieldsOf(ctx: HandlerContext): string[] | Response {
83
+ const fields = String(new URL(ctx.call.request.url).searchParams.get('fields') ?? '').split(',').map((f) => f.trim()).filter(Boolean);
84
+ const unknown = fields.filter((f) => !FIELDS.includes(f));
85
+ return !fields.length || unknown.length ? refuse(ctx, 400, 'invalid_params', `Invalid fields: ${unknown.join(',') || '(none)'}`) : fields;
86
+ }
87
+
88
+ export const project = (ctx: HandlerContext, v: Row, fields: string[]): Row => {
89
+ const served = ctx.get(VIDEO, String(v.id)) ?? {};
90
+ return Object.fromEntries(fields.map((f) => [f, served[f]]));
91
+ };
92
+
93
+ export const publishObj = (v: unknown): Row => (v && typeof v === 'object' && !Array.isArray(v) ? v as Row : {});
94
+
95
+ export const MB = 1024 * 1024;
96
+
97
+ export const UPLOAD_HOST = 'https://open-upload.tiktokapis.com';
98
+
99
+ export const invalid = (ctx: HandlerContext, message: string): Response => refuse(ctx, 400, 'invalid_param', message);
100
+
101
+ // source: https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide "Each chunk must be at least 5 MB but no greater than 64 MB, except for the final chunk, which can be greater than"
102
+ /** The chunk plan's refusal, or undefined when it holds: whole below 5 MB, chunks of 5–64 MB, floor(size/chunk) of
103
+ * them, 1–1000, at most 4 GB in all, and the final chunk (the whole of a one-chunk plan) at most 128 MB. */
104
+ export function chunkRefusal(size: number, chunk: number, count: number): string | undefined {
105
+ if (!Number.isInteger(size) || size <= 0) return 'video_size must be a positive integer';
106
+ // source: https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide "Maximum of 4GB"
107
+ if (size > 4 * 1024 * MB) return 'video_size is over 4GB';
108
+ if (!Number.isInteger(chunk) || chunk <= 0 || !Number.isInteger(count)) return 'chunk_size and total_chunk_count are required for FILE_UPLOAD';
109
+ if (size < 5 * MB) return chunk === size && count === 1 ? undefined : 'a video under 5 MB is uploaded whole: chunk_size equal to video_size, total_chunk_count 1';
110
+ // source: https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide "should be equal to"
111
+ if (count !== Math.floor(size / chunk)) return 'total_chunk_count must be video_size divided by chunk_size, rounded down';
112
+ if (count < 1 || count > 1000) return 'total_chunk_count must be between 1 and 1000';
113
+ if (count > 1 && (chunk < 5 * MB || chunk > 64 * MB)) return 'chunk_size must be between 5 MB and 64 MB';
114
+ // source: https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide "which can be greater than"
115
+ if (size - chunk * (count - 1) > 128 * MB) return 'the final chunk must be at most 128 MB';
116
+ return undefined;
117
+ }
118
+
119
+ export async function initVideo(ctx: HandlerContext, direct: boolean): Promise<Response> {
120
+ const c = authorized(ctx, direct ? 'video.publish' : 'video.upload');
121
+ if (c instanceof Response) return c;
122
+ const b = publishObj(ctx.body);
123
+ const post = publishObj(b.post_info);
124
+ const source = publishObj(b.source_info);
125
+ if (direct) {
126
+ // source: https://developers.tiktok.com/doc/content-posting-api-reference-direct-post "privacy_level is not specified or not among the options from the privacy_level_options returned in /publish/creator_info/query/ API."
127
+ if (!privacyOptions(c.person).includes(String(post.privacy_level))) return refuse(ctx, 403, 'privacy_level_option_mismatch', 'privacy_level is not specified or not among the options from the privacy_level_options returned in /publish/creator_info/query/ API.');
128
+ // source: https://developers.tiktok.com/doc/content-posting-api-reference-direct-post "The maximum length is 2200 in UTF-16 runes."
129
+ if (typeof post.title === 'string' && post.title.length > 2200) return invalid(ctx, 'title is over 2200 characters');
130
+ }
131
+ // Where the twin stops: Postiz uploads its videos (FILE_UPLOAD); a video pulled from a URL is the twin's gap, refused
132
+ // as such, never answered as though pulled
133
+ if (source.source === 'PULL_FROM_URL') return invalid(ctx, 'the twin does not model PULL_FROM_URL video posts');
134
+ if (source.source !== 'FILE_UPLOAD') return invalid(ctx, 'source must be FILE_UPLOAD or PULL_FROM_URL');
135
+ const size = Number(source.video_size);
136
+ const bad = chunkRefusal(size, Number(source.chunk_size), Number(source.total_chunk_count));
137
+ if (bad) return invalid(ctx, bad);
138
+ const n = await next(ctx, 'publish');
139
+ const id = `${direct ? 'v_pub_file' : 'v_inbox_file'}~v2.${n}`;
140
+ const uploadId = String(7_300_000_000_000_000_000n + BigInt(n));
141
+ const token = (await ctx.secret(`tiktok-upload-token:${id}`)).replace(/[^A-Za-z0-9]/g, '').slice(0, 32);
142
+ await ctx.write(PUBLISH, id, {
143
+ status: 'PROCESSING_UPLOAD', fail_reason: '', publicaly_available_post_id: [], uploaded_bytes: 0,
144
+ _person: c.person.id, _app: c.app.id, _kind: direct ? 'direct' : 'inbox', _media: 'VIDEO', _post_info: direct ? post : {},
145
+ _video_size: size, _chunk_size: Number(source.chunk_size), _chunks: Number(source.total_chunk_count),
146
+ _upload_id: uploadId, _upload_token_sha256: sha256(ctx, token), _upload_expires: seconds(ctx.occurredAt) + 3600,
147
+ }, direct ? 'videoInit' : 'inboxVideoInit');
148
+ return ok(ctx, { publish_id: id, upload_url: `${UPLOAD_HOST}/video/?upload_id=${uploadId}&upload_token=${token}` });
149
+ }
150
+
151
+ /** A pull from URLs: each must be under a URL property the app verified (403 url_ownership_unverified); the vendor
152
+ * pulls them through the World (ctx.vendorFetch, the World's one network) and a URL the World does not serve fails the
153
+ * post (photo_pull_failed, video_pull_failed). */
154
+ export async function pull(ctx: HandlerContext, c: Caller, kind: 'direct' | 'inbox', media: 'PHOTO', urls: string[], post: Row): Promise<Response> {
155
+ const app = ctx.row(APP, String(c.app.id))!;
156
+ const props = (app.url_properties as string[] | undefined) ?? [];
157
+ const verified = (url: string): boolean => {
158
+ let u: URL;
159
+ try { u = new URL(url); } catch { return false; }
160
+ // source: https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide "The media URL must use"
161
+ if (u.protocol !== 'https:') return false;
162
+ return props.some((p) => (p.includes('/') ? url.startsWith(p) : u.hostname === p || u.hostname.endsWith(`.${p}`)));
163
+ };
164
+ // source: https://developers.tiktok.com/doc/content-posting-api-reference-direct-post "the developer must verify the ownership of the URL prefix or domain"
165
+ if (!urls.every(verified)) return refuse(ctx, 403, 'url_ownership_unverified', 'To use PULL_FROM_URL as the media transfer method, the developer must verify the ownership of the URL prefix or domain.');
166
+ const n = await next(ctx, 'publish');
167
+ const id = `p_${kind === 'direct' ? 'pub' : 'inbox'}_url~v2.${n}`;
168
+ await ctx.write(PUBLISH, id, {
169
+ status: 'PROCESSING_DOWNLOAD', fail_reason: '', publicaly_available_post_id: [], downloaded_bytes: 0,
170
+ _person: c.person.id, _app: c.app.id, _kind: kind, _media: media, _post_info: post, _urls: urls,
171
+ }, 'contentInit');
172
+ let downloaded = 0;
173
+ let failed = false;
174
+ for (const url of urls) {
175
+ try {
176
+ const r = await ctx.vendorFetch(url, { method: 'GET' });
177
+ if (!r.ok) { failed = true; break; }
178
+ downloaded += (await r.arrayBuffer()).byteLength;
179
+ } catch { failed = true; break; }
180
+ }
181
+ const to = failed ? 'FAILED' : kind === 'direct' ? 'PUBLISH_COMPLETE' : 'SEND_TO_USER_INBOX';
182
+ const refused = ctx.legal(PUBLISH, 'status', 'contentInit', 'PROCESSING_DOWNLOAD', to, id, 'vendor');
183
+ if (refused) throw new Error(refused.message);
184
+ const postId = !failed && kind === 'direct' && post.privacy_level === 'PUBLIC_TO_EVERYONE' ? String(7_400_000_000_000_000_000 + (await next(ctx, 'video')) * 1024) : undefined;
185
+ await ctx.write(PUBLISH, id, {
186
+ status: to, downloaded_bytes: downloaded, ...(failed ? { fail_reason: 'photo_pull_failed' } : {}),
187
+ ...(postId ? { publicaly_available_post_id: [Number(postId)] } : {}),
188
+ }, 'contentInit');
189
+ return ok(ctx, { publish_id: id });
190
+ }
191
+
192
+ /** A chunk that is not the last: its progress kept, answered 206 with the bytes so far. */
193
+ // source: https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide "There are additional chunks yet to be uploaded."
194
+ export async function partialChunk(ctx: HandlerContext, id: string, now: number, size: number): Promise<Response> {
195
+ await ctx.write(PUBLISH, id, { uploaded_bytes: now }, 'uploadVideo');
196
+ return new Response(null, { status: 206, headers: { 'content-range': `bytes 0-${now - 1}/${size}` } });
197
+ }
@@ -0,0 +1,23 @@
1
+ // TikTok's state machines, shared by its lanes (architecture, "State"; a lane imports its vendor's states): a post's
2
+ // status, whether it came through the Content Posting API (the open lane) or the API for Business (the business lane).
3
+ // Its transitions name no operation: each is the vendor's move whichever lane's call it follows.
4
+ import type { StateField } from '@volter/world-core';
5
+
6
+ const STATUS = 'https://developers.tiktok.com/doc/content-posting-api-reference-get-video-status';
7
+
8
+ /** A post's `status`: an upload begins PROCESSING_UPLOAD ("Indicates that the upload is in process"), a pull from URL
9
+ * PROCESSING_DOWNLOAD (written so at the post's start); the vendor finishes it when the last chunk arrives or the pull
10
+ * ends. Not made by the twin: a post the creator makes from an inbox draft (PUBLISH_COMPLETE after SEND_TO_USER_INBOX,
11
+ * the creator's move in the app), a moderation that fails a post, and a cancelled pull (the cancel is unmodeled). */
12
+ export const publishStatus: StateField = {
13
+ initial: 'PROCESSING_UPLOAD',
14
+ transitions: [
15
+ { actor: 'vendor', from: ['PROCESSING_UPLOAD', 'PROCESSING_DOWNLOAD'], to: 'PUBLISH_COMPLETE', source: `${STATUS} "it indicates that the content has been posted"` },
16
+ { actor: 'vendor', from: ['PROCESSING_UPLOAD', 'PROCESSING_DOWNLOAD'], to: 'SEND_TO_USER_INBOX', source: `${STATUS} "Indicates that a notification has been sent to creator's inbox to complete the draft post using TikTok's editing flow."` },
17
+ { actor: 'vendor', from: ['PROCESSING_DOWNLOAD'], to: 'FAILED', source: `${STATUS} "Indicates that an error has occurred and the entire process has failed."` },
18
+ ],
19
+ };
20
+
21
+ export const publish = { state: { status: publishStatus }, notState: ['fail_reason', 'reason'] };
22
+
23
+ export const states = { publish };
@@ -0,0 +1,24 @@
1
+ import type { HandlerContext } from '@volter/world-core';
2
+ import { ACCESS, ACCESS_TTL, APP, CHUNK, CODE, FIELDS, FIELD_SCOPE, MB, PERSON, PUBLISH, REFRESH, REFRESH_TTL, UPLOAD_HOST, VIDEO, appByKey, authorized, bySha, chunkRefusal, clientSecret, credential, fieldsOf, initVideo, invalid, issue, logId, mediaUrl, next, oauthError, ok, openIdOf, partialChunk, pkceRefused, privacyOptions, project, publishObj, pull, refuse, seconds, sha256, type Caller, type Row, unionIdOf, videoMoment, videoPage, videosObj } from './shared.ts';
3
+ import { mp4Facts } from '../engine/mp4.ts';
4
+
5
+ // source: https://developers.tiktok.com/doc/tiktok-api-v2-get-user-info "endpoint returns some basic information for a given TikTok user."
6
+ export async function userInfo(ctx: HandlerContext): Promise<Response> {
7
+ const c = authorized(ctx);
8
+ if (c instanceof Response) return c;
9
+ const fields = String(new URL(ctx.call.request.url).searchParams.get('fields') ?? '').split(',').map((f) => f.trim()).filter(Boolean);
10
+ const unknown = fields.filter((f) => !(f in FIELD_SCOPE));
11
+ if (!fields.length || unknown.length) return refuse(ctx, 400, 'invalid_params', `Invalid fields: ${unknown.join(',') || '(none)'}`);
12
+ const missing = [...new Set(fields.map((f) => FIELD_SCOPE[f]!))].filter((s) => !c.scopes.includes(s));
13
+ if (missing.length) return refuse(ctx, 401, 'scope_not_authorized', `The user did not authorize the scope required for completing this request (${missing.join(',')}).`);
14
+ const p = c.person;
15
+ // "The total number of publicly posted videos by the user": the person's public videos, counted from the set
16
+ const videos = ctx.rowsRaw(VIDEO).filter((v) => v.deleted !== true && v._person === p.id && v._public === true).length;
17
+ const avatar = mediaUrl(ctx, `avatar-${String(p.id)}`);
18
+ const all: Row = {
19
+ open_id: openIdOf(ctx, c.app, p), union_id: unionIdOf(ctx, p), avatar_url: avatar, avatar_url_100: avatar, avatar_large_url: avatar, display_name: p.display_name,
20
+ bio_description: p.bio_description ?? '', profile_deep_link: `https://www.tiktok.com/@${String(p.username)}`, is_verified: p.is_verified === true, username: p.username,
21
+ follower_count: Number(p.follower_count ?? 0), following_count: Number(p.following_count ?? 0), likes_count: Number(p.likes_count ?? 0), video_count: videos,
22
+ };
23
+ return ok(ctx, { user: Object.fromEntries(fields.map((f) => [f, all[f]])) });
24
+ }
@@ -0,0 +1,97 @@
1
+ import type { HandlerContext } from '@volter/world-core';
2
+ import { ACCESS, ACCESS_TTL, APP, CHUNK, CODE, FIELDS, FIELD_SCOPE, MB, PERSON, PUBLISH, REFRESH, REFRESH_TTL, UPLOAD_HOST, VIDEO, appByKey, authorized, bySha, chunkRefusal, clientSecret, credential, fieldsOf, initVideo, invalid, issue, logId, mediaUrl, next, oauthError, ok, openIdOf, partialChunk, pkceRefused, privacyOptions, project, publishObj, pull, refuse, seconds, sha256, type Caller, type Row, unionIdOf, videoMoment, videoPage, videosObj } from './shared.ts';
3
+ import { mp4Facts } from '../engine/mp4.ts';
4
+
5
+ // source: https://developers.tiktok.com/doc/tiktok-api-v2-video-list "sorted by"
6
+ export async function videoList(ctx: HandlerContext): Promise<Response> {
7
+ const c = authorized(ctx, 'video.list');
8
+ if (c instanceof Response) return c;
9
+ const fields = fieldsOf(ctx);
10
+ if (fields instanceof Response) return fields;
11
+ const b = videosObj(ctx.body);
12
+ // source: https://developers.tiktok.com/doc/tiktok-api-v2-video-list "Default is 10. Maximum is 20."
13
+ const max = b.max_count === undefined ? 10 : Number(b.max_count);
14
+ if (!Number.isInteger(max) || max < 1 || max > 20) return refuse(ctx, 400, 'invalid_params', 'max_count must be between 1 and 20');
15
+ // source: https://developers.tiktok.com/doc/tiktok-api-v2-video-list "the cursor value is a"
16
+ const cursor = b.cursor === undefined ? Infinity : Number(b.cursor);
17
+ const { page, cursor: after, more } = videoPage(ctx, String(c.person.id), cursor, max);
18
+ return ok(ctx, { videos: page.map((v) => project(ctx, v, fields)), cursor: after, has_more: more });
19
+ }
20
+
21
+ // source: https://developers.tiktok.com/doc/tiktok-api-v2-video-query "verifies that the videos belong to the user and returns video details."
22
+ export async function videoQuery(ctx: HandlerContext): Promise<Response> {
23
+ const c = authorized(ctx, 'video.list');
24
+ if (c instanceof Response) return c;
25
+ const fields = fieldsOf(ctx);
26
+ if (fields instanceof Response) return fields;
27
+ const ids = Array.isArray(videosObj(videosObj(ctx.body).filters).video_ids) ? (videosObj(videosObj(ctx.body).filters).video_ids as unknown[]).map(String) : [];
28
+ // source: https://developers.tiktok.com/doc/tiktok-api-v2-video-query "Up to 20 video IDs can be included per request."
29
+ if (!ids.length || ids.length > 20) return refuse(ctx, 400, 'invalid_params', 'filters.video_ids must hold 1 to 20 ids');
30
+ const mine = ctx.rowsRaw(VIDEO).filter((v) => v.deleted !== true && v._person === c.person.id && ids.includes(String(v.id)));
31
+ return ok(ctx, { videos: mine.map((v) => project(ctx, v, fields)) });
32
+ }
33
+
34
+ // source: https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide "All parts are uploaded."
35
+ export async function uploadVideo(ctx: HandlerContext): Promise<Response> {
36
+ const q = new URL(ctx.call.request.url).searchParams;
37
+ const p = ctx.rowsRaw(PUBLISH).find((x) => x.deleted !== true && x._upload_id === q.get('upload_id'));
38
+ // source: https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide "TikTok cannot find a valid upload task given the"
39
+ if (!p || p._upload_token_sha256 !== sha256(ctx, q.get('upload_token') ?? '')) return new Response(null, { status: 404 });
40
+ // source: https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide "has expired."
41
+ if (Number(p._upload_expires) <= seconds(ctx.occurredAt)) return new Response(null, { status: 403 });
42
+ const size = Number(p._video_size);
43
+ const id = String(p.id);
44
+ // the chunks received are one subject each; the progress is their sum, never a running total
45
+ const received = (): Row[] => ctx.rowsRaw(CHUNK).filter((k) => k.publish === id);
46
+ const uploaded = received().reduce((n, k) => n + Number(k.bytes), 0);
47
+ const range = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(ctx.call.request.headers.get('content-range') ?? '');
48
+ const bytes = new Uint8Array(await ctx.call.request.clone().arrayBuffer());
49
+ const progress = { 'content-range': `bytes 0-${Math.max(uploaded - 1, 0)}/${size}` };
50
+ if (!range) return new Response(null, { status: 400, headers: progress });
51
+ const [first, last, total] = [Number(range[1]), Number(range[2]), Number(range[3])];
52
+ // source: https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide "does not reflect the actual upload progress."
53
+ if (p.status !== 'PROCESSING_UPLOAD' || first !== uploaded || total !== size) return new Response(null, { status: 416, headers: progress });
54
+ const chunk = Number(p._chunk_size);
55
+ const index = Math.floor(first / chunk);
56
+ const expected = index === Number(p._chunks) - 1 ? size - first : chunk;
57
+ // source: https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide "does not reflect the true byte size of the binary in the request body."
58
+ if (last - first + 1 !== bytes.byteLength || bytes.byteLength !== expected) return new Response(null, { status: 400, headers: progress });
59
+ await ctx.blobs.put(`publish:${id}:${index}`, bytes);
60
+ await ctx.record(CHUNK, { publish: id, index, first, bytes: bytes.byteLength }, `${id}:${index}`);
61
+ const now = received().reduce((n, k) => n + Number(k.bytes), 0);
62
+ if (now < size) return partialChunk(ctx, id, now, size);
63
+ // the last chunk: the whole file is kept, and the post moves
64
+ const parts: Uint8Array[] = [];
65
+ for (let i = 0; i < Number(p._chunks); i++) parts.push((await ctx.blobs.get(`publish:${id}:${i}`)) ?? new Uint8Array(0));
66
+ const whole = new Uint8Array(size);
67
+ let at = 0;
68
+ for (const part of parts) { whole.set(part, at); at += part.byteLength; }
69
+ const direct = p._kind === 'direct';
70
+ const to = direct ? 'PUBLISH_COMPLETE' : 'SEND_TO_USER_INBOX';
71
+ const refused = ctx.legal(PUBLISH, 'status', 'uploadVideo', 'PROCESSING_UPLOAD', to, id, 'vendor');
72
+ if (refused) throw new Error(refused.message);
73
+ const post = publishObj(p._post_info);
74
+ let postIds: number[] = [];
75
+ if (direct) {
76
+ const facts = mp4Facts(whole);
77
+ const n = await next(ctx, 'video');
78
+ const videoId = String(7_400_000_000_000_000_000 + n * 1024);
79
+ const person = ctx.row('_person', String(p._person));
80
+ const isPublic = post.privacy_level === 'PUBLIC_TO_EVERYONE';
81
+ const title = typeof post.title === 'string' ? post.title : '';
82
+ // a video as the Video Object answers it ("Max length: 150" for title and description)
83
+ await ctx.write(VIDEO, videoId, {
84
+ id: videoId, create_time: seconds(ctx.occurredAt), _moment: videoMoment(ctx, String(person?.id ?? p._person)), cover_image_url: mediaUrl(ctx, `cover-${videoId}`), share_url: `https://www.tiktok.com/@${String(person?.username)}/video/${videoId}`,
85
+ video_description: title.slice(0, 150), duration: facts.duration, height: facts.height, width: facts.width, title: title.slice(0, 150),
86
+ embed_html: `<blockquote class="tiktok-embed" cite="https://www.tiktok.com/@${String(person?.username)}/video/${videoId}" data-video-id="${videoId}"></blockquote>`,
87
+ embed_link: `https://www.tiktok.com/player/v1/${videoId}`, like_count: 0, comment_count: 0, share_count: 0, view_count: 0, is_aigc: post.is_aigc === true,
88
+ _person: p._person, _public: isPublic, _publish: id,
89
+ }, 'video.publish');
90
+ // source: https://developers.tiktok.com/doc/content-posting-api-reference-get-video-status "is returned only if the post is published for public viewership"
91
+ if (isPublic) postIds = [Number(videoId)];
92
+ }
93
+ await ctx.write(PUBLISH, id, { status: to, uploaded_bytes: now, publicaly_available_post_id: postIds }, 'uploadVideo');
94
+ await ctx.blobs.put(`publish:${id}`, whole);
95
+ for (let i = 0; i < Number(p._chunks); i++) await ctx.blobs.remove(`publish:${id}:${i}`);
96
+ return new Response(null, { status: 201, headers: { 'content-range': `bytes 0-${now - 1}/${size}` } });
97
+ }
package/package.json CHANGED
@@ -1,40 +1,32 @@
1
1
  {
2
2
  "name": "@volter/twin-tiktok",
3
- "version": "0.1.37",
4
- "description": "Local TikTok twin — the real tiktok.com authorization page, the full Login Kit OAuth round trip (token, refresh, revoke, client_credentials) and the Display API user/video reads with TikTok's own comma-separated scopes, per-app open_id, field-level scope gating and both of its error envelopes, so an unmodified TikTok integration completes sign-in-with-TikTok against it. Built on @volter/world-core.",
3
+ "version": "1.0.0",
4
+ "description": "TikTok twin (Protocol 3): Login Kit's authorize page and tokens, the Content Posting API (videos by chunked upload, direct and to the inbox; photos pulled from verified URLs; post status) and the Display API (user info and stats, videos), as Postiz calls them. Built on @volter/world-core.",
5
5
  "keywords": [
6
6
  "twin",
7
- "local",
8
- "mock",
9
- "mirror",
10
- "simulator",
11
- "fixtures",
12
- "testing",
13
- "oauth",
14
- "oauth2",
15
7
  "tiktok",
16
- "login-kit",
17
- "display-api",
18
- "identity",
19
- "sign-in-with-tiktok"
8
+ "content-posting",
9
+ "login-kit"
20
10
  ],
21
11
  "author": "Volter (https://github.com/volter-ai)",
22
12
  "license": "Apache-2.0",
23
13
  "files": [
24
14
  "src",
25
- "client",
15
+ "journeys",
16
+ "business",
17
+ "open",
26
18
  "README.md",
27
19
  "LICENSE",
28
20
  "!**/*.test.ts",
29
- "!**/*.test.tsx",
30
- "dist"
21
+ "dist",
22
+ "generated"
31
23
  ],
32
24
  "repository": {
33
25
  "type": "git",
34
- "url": "git+https://github.com/volter-ai/twin.git",
35
- "directory": "packages/twin/tiktok"
26
+ "url": "git+https://github.com/volter-ai/twin-packs-p3.git",
27
+ "directory": "tiktok"
36
28
  },
37
- "homepage": "https://world.volter.ai/twins/tiktok",
29
+ "homepage": "https://github.com/volter-ai/twin-packs-p3/tree/main/tiktok#readme",
38
30
  "type": "module",
39
31
  "exports": {
40
32
  ".": {
@@ -45,28 +37,8 @@
45
37
  "bin": {
46
38
  "world-tiktok": "dist/src/cli.js"
47
39
  },
48
- "scripts": {
49
- "test": "bun test src/*.test.ts",
50
- "typecheck": "tsc --noEmit",
51
- "build": "node ../../../scripts/publish/build.mjs",
52
- "prepack": "node ../../../scripts/publish/prepare-publish.mjs prepack",
53
- "postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
54
- },
55
- "dependencies": {
56
- "react": "^19.2.7",
57
- "react-dom": "^19.2.7"
58
- },
59
40
  "peerDependencies": {
60
- "@volter/world-core": "2.0.37"
61
- },
62
- "devDependencies": {
63
- "@types/bun": "^1.2.20",
64
- "@types/node": "^24.0.0",
65
- "@types/react": "^19.2.17",
66
- "@types/react-dom": "^19.2.3",
67
- "@volter/world-core": "2.0.37",
68
- "@volter/world-tooling": "0.1.0",
69
- "typescript": "^5.9.0"
41
+ "@volter/world-core": "^3.0.38"
70
42
  },
71
43
  "engines": {
72
44
  "node": ">=22.3"
package/src/cli.ts CHANGED
@@ -1,43 +1,22 @@
1
1
  #!/usr/bin/env node
2
- // world-tiktok CLI: serve the TikTok twin, or run conformance.
3
- //
4
- // `serve` starts the twin. `mirror` starts the same twin with tiktok.com's profile and player in
5
- // front of it (tiktok-mirror-ui.ts: the shell at `/`, everything else the twin's own fetch adapter),
6
- // and prints both screens an operator asks to "see": the mirror, and a ready-to-open authorize URL
7
- // for the authorization page the twin serves at the vendor's own path.
2
+ // world-tiktok: serve the tiktok twin.
8
3
  import { hasFlag, optionValue } from '@volter/world-core/args';
9
4
  import { keepProcessAlive } from '@volter/world-core/lifecycle';
10
- import { createTikTokTwinServer } from './tiktok-server.ts';
11
- import { createTiktokMirrorServer } from './tiktok-mirror-ui.ts';
12
- import { DEFAULT_CLIENT_KEY, defaultRedirectUris } from './tiktok-store.ts';
5
+ import { createTiktokTwinServer } from './server.ts';
13
6
 
14
7
  const [cmd, ...rest] = process.argv.slice(2);
15
8
  const port = Number(optionValue(rest, '--port', '0')) || undefined;
16
9
  const root = optionValue(rest, '--root') || undefined;
17
- const readOnly = hasFlag(rest, '--read-only'); // a twin accepts writes unless started read-only
10
+ const readOnly = hasFlag(rest, '--read-only');
11
+ // the World's managed Postgres, which the runtime binds a pack declaring managedDatabase to
12
+ const database = optionValue(rest, '--database') || undefined;
13
+ // the World's scenario file, for a model vendor's turns
14
+ const scenarioPath = optionValue(rest, '--scenario') || undefined;
18
15
 
19
- if (cmd === 'serve' || cmd === 'mirror') {
20
- const options = { readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}) };
21
- const s = cmd === 'mirror' ? await createTiktokMirrorServer(options) : await createTikTokTwinServer(options);
22
- const origin = `http://127.0.0.1:${s.port}`;
23
- process.stdout.write(`tiktok twin (Login Kit OAuth + Display API + Content Posting)${readOnly ? ' [read-only]' : ''} at ${origin}\n`);
24
- if (cmd === 'mirror') {
25
- process.stdout.write(`mirror (tiktok.com profile + player): ${origin}/\n`);
26
- const url = new URL(`${origin}/v2/auth/authorize`);
27
- url.searchParams.set('client_key', DEFAULT_CLIENT_KEY);
28
- url.searchParams.set('response_type', 'code');
29
- url.searchParams.set('redirect_uri', defaultRedirectUris(origin)[0]!);
30
- url.searchParams.set('scope', 'user.info.basic,user.info.profile,video.list');
31
- url.searchParams.set('state', 'twin-demo-state');
32
- process.stdout.write(`authorization page: ${url.toString()}\n`);
33
- }
16
+ if (cmd === 'serve' || cmd === undefined) {
17
+ const s = await createTiktokTwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}), ...(database ? { database } : {}), ...(scenarioPath ? { scenarioPath } : {}) });
18
+ process.stdout.write(`tiktok twin${readOnly ? ' [read-only]' : ''} at http://127.0.0.1:${s.port}\n`);
34
19
  await keepProcessAlive();
35
- } else if (cmd === 'conformance') {
36
- // dev-only; lazy so the bin runs without @volter/world-tooling in the runtime graph
37
- const { checkTikTokConformance } = await import('./tiktok-conformance.ts');
38
- const report = await checkTikTokConformance({ ...(root ? { root } : {}) });
39
- process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
40
- if (!report.ok) process.exitCode = 1;
41
20
  } else {
42
- process.stdout.write('Usage: world-tiktok serve|mirror|conformance [--port N] [--root DIR] [--read-only]\n');
21
+ process.stdout.write('Usage: world-tiktok serve [--port N] [--root DIR] [--read-only] [--database URL] [--scenario FILE]\n');
43
22
  }
package/src/fetch.ts ADDED
@@ -0,0 +1,10 @@
1
+ // GENERATED by scripts/create-pack.ts (--init, --index): a fixed file. The kernel serves the vendor's discovery door and
2
+ // sends each request to the lane its manifest's routes name (architecture, "Other wires: lanes").
3
+ import { createVendorFetch, type DerivedFetch, type PackFetchOptions } from '@volter/world-core';
4
+ import { manifest } from './manifest.ts';
5
+ import { createBusinessLaneFetch, manifest as business_manifest } from '../business/src/index.ts';
6
+ import { createOpenLaneFetch, manifest as open_manifest } from '../open/src/index.ts';
7
+
8
+ export function createTiktokFetch(options: PackFetchOptions = {}): DerivedFetch {
9
+ return createVendorFetch(manifest, { "business": { fetch: createBusinessLaneFetch(options), manifest: business_manifest }, "open": { fetch: createOpenLaneFetch(options), manifest: open_manifest } });
10
+ }