@volter/twin-x 0.1.0 → 0.1.1
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 +22 -8
- package/dist/client/x-mirror.bundle.js +90 -90
- package/dist/src/index.js +4 -2
- package/dist/src/x-capabilities.js +168 -1
- package/dist/src/x-oauth1.d.ts +24 -0
- package/dist/src/x-oauth1.js +145 -0
- package/dist/src/x-problems.d.ts +7 -0
- package/dist/src/x-problems.js +18 -0
- package/dist/src/x-server.js +51 -13
- package/dist/src/x-twin.d.ts +4 -0
- package/dist/src/x-twin.js +58 -18
- package/package.json +3 -3
- package/src/index.ts +4 -2
- package/src/x-capabilities.ts +175 -1
- package/src/x-oauth1.ts +138 -0
- package/src/x-problems.ts +19 -0
- package/src/x-server.ts +43 -8
- package/src/x-twin.ts +57 -18
package/src/x-problems.ts
CHANGED
|
@@ -55,6 +55,25 @@ export function forbiddenProblem(detail: string): XResponse {
|
|
|
55
55
|
};
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* An OAuth 1.0a token whose App permission does not reach the endpoint (a Read-only token posting).
|
|
60
|
+
* X's v2 answers this with its own problem type rather than the scope wording: the type and detail
|
|
61
|
+
* are the widely-reported wire strings, not a fetched page — EXTRAPOLATION, pinned by
|
|
62
|
+
* `x.auth.oauth1_permissions_wording`.
|
|
63
|
+
*/
|
|
64
|
+
export function oauth1PermissionsProblem(): XResponse {
|
|
65
|
+
return {
|
|
66
|
+
status: 403,
|
|
67
|
+
body: {
|
|
68
|
+
title: 'Forbidden',
|
|
69
|
+
type: 'https://api.twitter.com/2/problems/oauth1-permissions',
|
|
70
|
+
status: 403,
|
|
71
|
+
detail: 'Your client app is not configured with the appropriate oauth1 app permissions for this endpoint.',
|
|
72
|
+
},
|
|
73
|
+
headers: { ...PROBLEM },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
58
77
|
/** An unmodelled /2 route fails like the vendor: 404 problem, never a fake success. */
|
|
59
78
|
export function notFoundProblem(): XResponse {
|
|
60
79
|
return {
|
package/src/x-server.ts
CHANGED
|
@@ -59,6 +59,46 @@ function bytesResponse(request: Request, bytes: Uint8Array, contentType: string)
|
|
|
59
59
|
});
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* An RFC 7578 multipart body, read as BYTES. The `media` part is binary whether or not its
|
|
64
|
+
* disposition names a filename: twitter-api-v2@1.29.1's FormDataHelper — what Postiz's
|
|
65
|
+
* `client.v2.uploadMedia` sends each APPEND with — writes `Content-Disposition: form-data;
|
|
66
|
+
* name="media"` and `Content-Type: application/octet-stream` with NO filename, and a WHATWG
|
|
67
|
+
* `formData()` parse turns such a part into a UTF-8-decoded string, corrupting the bytes. Every other
|
|
68
|
+
* part is a text field. Null when the body is not the multipart its content type declares.
|
|
69
|
+
*/
|
|
70
|
+
function parseMultipart(body: Uint8Array, contentType: string): { fields: Record<string, string>; media?: Uint8Array } | null {
|
|
71
|
+
const b = /boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(contentType);
|
|
72
|
+
if (!b) return null;
|
|
73
|
+
const boundary = b[1] ?? b[2];
|
|
74
|
+
const buf = Buffer.from(body.buffer, body.byteOffset, body.byteLength);
|
|
75
|
+
// A delimiter is CRLF "--boundary" (RFC 2046 §5.1.1); the first may open the body with no CRLF
|
|
76
|
+
// before it, anything ahead of it is preamble. A boundary string not at a line start is content.
|
|
77
|
+
const delimiter = Buffer.from(`\r\n--${boundary}`);
|
|
78
|
+
const first = Buffer.from(`--${boundary}`);
|
|
79
|
+
let pos = buf.subarray(0, first.length).equals(first) ? first.length : (() => { const i = buf.indexOf(delimiter); return i < 0 ? -1 : i + delimiter.length; })();
|
|
80
|
+
if (pos < 0) return null;
|
|
81
|
+
const fields: Record<string, string> = {};
|
|
82
|
+
let media: Uint8Array | undefined;
|
|
83
|
+
for (;;) {
|
|
84
|
+
if (buf[pos] === 0x2d && buf[pos + 1] === 0x2d) break; // the closing delimiter
|
|
85
|
+
while (buf[pos] === 0x20 || buf[pos] === 0x09) pos += 1; // transport padding
|
|
86
|
+
if (buf[pos] !== 0x0d || buf[pos + 1] !== 0x0a) return null;
|
|
87
|
+
const headEnd = buf.indexOf('\r\n\r\n', pos + 2);
|
|
88
|
+
if (headEnd < 0) return null;
|
|
89
|
+
const head = buf.subarray(pos + 2, headEnd).toString('utf8');
|
|
90
|
+
const next = buf.indexOf(delimiter, headEnd + 2);
|
|
91
|
+
if (next < 0) return null;
|
|
92
|
+
const content = buf.subarray(Math.min(headEnd + 4, next), next);
|
|
93
|
+
const disposition = head.split('\r\n').find((line) => /^content-disposition\s*:/i.test(line)) ?? '';
|
|
94
|
+
const name = /;\s*name="([^"]*)"/i.exec(disposition)?.[1];
|
|
95
|
+
if (name === 'media') media = new Uint8Array(content);
|
|
96
|
+
else if (name !== undefined) fields[name] = content.toString('utf8');
|
|
97
|
+
pos = next + delimiter.length;
|
|
98
|
+
}
|
|
99
|
+
return { fields, ...(media ? { media } : {}) };
|
|
100
|
+
}
|
|
101
|
+
|
|
62
102
|
export function createXTwinFetch(options: XTwinFetchOptions = {}): (request: Request) => Promise<Response> {
|
|
63
103
|
const adapted = createTwinFetchFromHandler(handleXTwinRequest, {
|
|
64
104
|
...options,
|
|
@@ -86,16 +126,11 @@ export function createXTwinFetch(options: XTwinFetchOptions = {}): (request: Req
|
|
|
86
126
|
}
|
|
87
127
|
const type = request.headers.get('content-type') ?? '';
|
|
88
128
|
if (request.method === 'POST' && MULTIPART_UPLOAD_PATH.test(url.pathname) && /^multipart\/form-data/i.test(type)) {
|
|
89
|
-
|
|
90
|
-
|
|
129
|
+
const parsed = parseMultipart(new Uint8Array(await request.arrayBuffer()), type);
|
|
130
|
+
if (!parsed) {
|
|
91
131
|
return Response.json({ errors: [{ parameters: { body: ['(unparseable)'] }, message: 'The multipart body could not be parsed.' }], title: 'Invalid Request', detail: 'One or more parameters to your request was invalid.', type: 'https://api.x.com/2/problems/invalid-request' }, { status: 400 });
|
|
92
132
|
}
|
|
93
|
-
const fields
|
|
94
|
-
let media: Uint8Array | undefined;
|
|
95
|
-
for (const [name, value] of form.entries() as Iterable<[string, unknown]>) {
|
|
96
|
-
if (typeof value === 'string') fields[name] = value;
|
|
97
|
-
else if (name === 'media' && value instanceof Blob) media = new Uint8Array(await value.arrayBuffer());
|
|
98
|
-
}
|
|
133
|
+
const { fields, media } = parsed;
|
|
99
134
|
const headers: Record<string, string> = {};
|
|
100
135
|
request.headers.forEach((value, key) => { headers[key] = value; });
|
|
101
136
|
const invoke = () => handleXTwinRequest({
|
package/src/x-twin.ts
CHANGED
|
@@ -35,19 +35,21 @@
|
|
|
35
35
|
// default projection is the vendor's three fields, and `projectPost` is the ONE place a stored
|
|
36
36
|
// post becomes a response body.
|
|
37
37
|
//
|
|
38
|
-
// AUTH. OAuth
|
|
39
|
-
//
|
|
40
|
-
//
|
|
38
|
+
// AUTH. OAuth is xidentity's and stays there. This pack CONSUMES what it mints: an OAuth 2.0 user
|
|
39
|
+
// bearer — opaque, scope-carrying; `/_twin/tokens` registers one locally, which is what a world
|
|
40
|
+
// does instead of running the authorize leg for every test — or an OAuth 1.0a User Context
|
|
41
|
+
// signature over an access token xidentity's three legs issued (x-oauth1.ts).
|
|
41
42
|
//
|
|
42
43
|
// DETERMINISM. Every served response is a pure function of (request, stored state). Nothing on
|
|
43
44
|
// the read path reads a clock: `created_at` is what the WRITE stored, and the `next_token` a page
|
|
44
45
|
// hands back is derived from the id of the last row on that page, never from entropy or time.
|
|
45
|
-
import { applyTwinWrite, projectResources } from '@volter/world-core';
|
|
46
|
+
import { applyTwinWrite, projectResources, worldNow } from '@volter/world-core';
|
|
46
47
|
import {
|
|
47
48
|
forbiddenProblem,
|
|
48
49
|
invalidRequestProblem,
|
|
49
50
|
lookupResult,
|
|
50
51
|
notFoundProblem,
|
|
52
|
+
oauth1PermissionsProblem,
|
|
51
53
|
rateLimitExceeded,
|
|
52
54
|
readOnlyRefusal,
|
|
53
55
|
resourceNotFoundError,
|
|
@@ -56,6 +58,7 @@ import {
|
|
|
56
58
|
type XResponse,
|
|
57
59
|
} from './x-problems.ts';
|
|
58
60
|
import { missingScopes, parseScopeList, REQUIRED_SCOPES } from './x-scopes.ts';
|
|
61
|
+
import { isOAuth1Header, scopesOfAccessLevel, verifyOAuth1 } from './x-oauth1.ts';
|
|
59
62
|
import {
|
|
60
63
|
clearSegments,
|
|
61
64
|
listMediaIds,
|
|
@@ -159,7 +162,7 @@ function newestFirst(posts: Resource[]): Resource[] {
|
|
|
159
162
|
// unregistered, malformed or absent bearer is the vendor's about:blank 401 — never a local
|
|
160
163
|
// exception, and never a pass.
|
|
161
164
|
|
|
162
|
-
type Authorized = { accountId: string; scopes: string[]; token: string };
|
|
165
|
+
type Authorized = { accountId: string; scopes: string[]; token: string; oauth1?: true };
|
|
163
166
|
|
|
164
167
|
function bearerFrom(headers: Record<string, string> | undefined): string | undefined {
|
|
165
168
|
if (!headers) return undefined;
|
|
@@ -171,7 +174,16 @@ function bearerFrom(headers: Record<string, string> | undefined): string | undef
|
|
|
171
174
|
return undefined;
|
|
172
175
|
}
|
|
173
176
|
|
|
174
|
-
function authorize(all: Resource[],
|
|
177
|
+
function authorize(all: Resource[], request: XRequest): Authorized | XResponse {
|
|
178
|
+
const headers = request.headers;
|
|
179
|
+
const authorization = headers ? Object.entries(headers).find(([k]) => k.toLowerCase() === 'authorization')?.[1] : undefined;
|
|
180
|
+
if (isOAuth1Header(authorization)) {
|
|
181
|
+
// X's alternate scheme for every route here: a request signed with an access token the
|
|
182
|
+
// person approved at xidentity's screen. Anything that does not verify is the same 401.
|
|
183
|
+
const user = verifyOAuth1({ method: request.method, path: request.path, headers: headers ?? {}, root: request.root, publicBase: request.publicBase });
|
|
184
|
+
if (!user) return unauthorizedProblem();
|
|
185
|
+
return { accountId: user.userId, scopes: scopesOfAccessLevel(user.accessLevel), token: user.token, oauth1: true };
|
|
186
|
+
}
|
|
175
187
|
const token = bearerFrom(headers);
|
|
176
188
|
if (!token) return unauthorizedProblem();
|
|
177
189
|
const row = ofType(all, 'token').find((t) => String(t.id) === token);
|
|
@@ -184,6 +196,7 @@ const isResponse = (value: Authorized | XResponse): value is XResponse => 'statu
|
|
|
184
196
|
function scopeRefusal(auth: Authorized, operation: keyof typeof REQUIRED_SCOPES): XResponse | undefined {
|
|
185
197
|
const missing = missingScopes(auth.scopes, REQUIRED_SCOPES[operation]);
|
|
186
198
|
if (missing.length === 0) return undefined;
|
|
199
|
+
if (auth.oauth1) return oauth1PermissionsProblem();
|
|
187
200
|
return forbiddenProblem(`This request requires the ${missing.join(', ')} scope(s), which this token does not hold.`);
|
|
188
201
|
}
|
|
189
202
|
|
|
@@ -266,9 +279,14 @@ const USER_FIELDS_DOCUMENTED = new Set([
|
|
|
266
279
|
* which is a pure function of the account's live posts. `public_metrics` is NOT here: two of its
|
|
267
280
|
* counts (listed, like) have no state behind them, and a zero the twin cannot know is a fabrication. */
|
|
268
281
|
const USER_FIELDS_MODELLED = new Set([
|
|
269
|
-
'created_at', 'description', 'id', 'location', 'most_recent_tweet_id', 'name', 'protected', 'url', 'username', 'verified',
|
|
282
|
+
'created_at', 'description', 'id', 'location', 'most_recent_tweet_id', 'name', 'profile_image_url', 'protected', 'url', 'username', 'verified',
|
|
270
283
|
]);
|
|
271
284
|
|
|
285
|
+
/** The picture X shows for an account that never uploaded one: every X account has a
|
|
286
|
+
* `profile_image_url`, and this is its value until the person sets a photo. The twin serves the
|
|
287
|
+
* URL, not the bytes (it claims no abs.twimg.com), as it serves no profile photos at all. */
|
|
288
|
+
export const X_DEFAULT_PROFILE_IMAGE_URL = 'https://abs.twimg.com/sticky/default_profile_images/default_profile_normal.png';
|
|
289
|
+
|
|
272
290
|
/** Read `user.fields` off the query string, refusing the way `tweet.fields` refuses. */
|
|
273
291
|
function parseUserFields(url: URL): Set<string> | XResponse {
|
|
274
292
|
const userFields = new Set<string>();
|
|
@@ -395,6 +413,9 @@ function projectUser(account: Resource, userFields: Set<string>, all: Resource[]
|
|
|
395
413
|
if (userFields.has('description')) out.description = typeof account.description === 'string' ? account.description : '';
|
|
396
414
|
if (userFields.has('location') && typeof account.location === 'string' && account.location !== '') out.location = account.location;
|
|
397
415
|
if (userFields.has('url')) out.url = typeof account.url === 'string' ? account.url : '';
|
|
416
|
+
if (userFields.has('profile_image_url')) {
|
|
417
|
+
out.profile_image_url = typeof account.profile_image_url === 'string' && account.profile_image_url !== '' ? account.profile_image_url : X_DEFAULT_PROFILE_IMAGE_URL;
|
|
418
|
+
}
|
|
398
419
|
if (userFields.has('protected')) out.protected = account.protected === true;
|
|
399
420
|
if (userFields.has('verified')) out.verified = account.verified === true;
|
|
400
421
|
if (userFields.has('most_recent_tweet_id')) {
|
|
@@ -677,6 +698,13 @@ const POST_FIELDS_UNMODELLED = new Set([
|
|
|
677
698
|
'nullcast', 'paid_partnership', 'poll', 'reply_settings', 'share_with_followers',
|
|
678
699
|
]);
|
|
679
700
|
const REPLY_FIELDS_UNMODELLED = new Set(['exclude_reply_user_ids', 'auto_populate_reply_metadata']);
|
|
701
|
+
/** The two disclosure labels, when they label nothing. `made_with_ai` ("this post contains AI-generated
|
|
702
|
+
* media") and `paid_partnership` ("this post is a paid partnership") are booleans whose `false` is the
|
|
703
|
+
* post every create here already makes, unlabelled; Postiz sends both on every post and reply, `false`
|
|
704
|
+
* unless the person ticked the box (x.provider.ts, `assetBoolean(...) || false`). `false` is read as the
|
|
705
|
+
* absent value it is; `true` — a label this twin does not store or show — stays refused by name, and
|
|
706
|
+
* so does a non-boolean. */
|
|
707
|
+
const POST_DISCLOSURE_FIELDS = new Set(['made_with_ai', 'paid_partnership']);
|
|
680
708
|
|
|
681
709
|
/** A post id in a request body: a STRING matching ^[0-9]{1,19}$, as the schema types it. */
|
|
682
710
|
function postIdField(value: unknown, parameter: string): string | XResponse {
|
|
@@ -689,6 +717,10 @@ function postIdField(value: unknown, parameter: string): string | XResponse {
|
|
|
689
717
|
async function createPost(request: XRequest, all: Resource[], auth: Authorized, payload: Record<string, any>): Promise<XResponse> {
|
|
690
718
|
for (const key of Object.keys(payload)) {
|
|
691
719
|
if (POST_FIELDS_MODELLED.has(key)) continue;
|
|
720
|
+
if (POST_DISCLOSURE_FIELDS.has(key) && payload[key] === false) continue;
|
|
721
|
+
if (POST_DISCLOSURE_FIELDS.has(key) && payload[key] !== true) {
|
|
722
|
+
return invalidRequestProblem({ [key]: [JSON.stringify(payload[key])] }, `The \`${key}\` field must be a boolean.`);
|
|
723
|
+
}
|
|
692
724
|
if (POST_FIELDS_UNMODELLED.has(key)) return unmodelledOption(key, JSON.stringify(payload[key]));
|
|
693
725
|
return invalidRequestProblem({ [key]: [JSON.stringify(payload[key])] }, `The \`${key}\` field is not a parameter of this request.`);
|
|
694
726
|
}
|
|
@@ -752,7 +784,7 @@ async function createPost(request: XRequest, all: Resource[], auth: Authorized,
|
|
|
752
784
|
|
|
753
785
|
// a post's id is the snowflake high-water mark + 1: timelines, since_id and pagination order by it
|
|
754
786
|
const id = mintSnowflakeId(all, await listMediaIds(request.root));
|
|
755
|
-
const createdAt = request.occurredAt ??
|
|
787
|
+
const createdAt = request.occurredAt ?? worldNow();
|
|
756
788
|
await applyTwinWrite(SERVICE, {
|
|
757
789
|
operation: isReply ? 'x.post.reply' : isQuote ? 'x.post.quote' : 'x.post.create',
|
|
758
790
|
subjectType: 'post',
|
|
@@ -791,7 +823,7 @@ async function deletePost(request: XRequest, all: Resource[], auth: Authorized,
|
|
|
791
823
|
subjectType: 'post',
|
|
792
824
|
subjectId: id,
|
|
793
825
|
fields: { ...Object.fromEntries(Object.entries(post).filter(([k]) => k !== 'type' && k !== 'id' && k !== 'updatedAt')), deleted: true },
|
|
794
|
-
occurredAt: request.occurredAt ??
|
|
826
|
+
occurredAt: request.occurredAt ?? worldNow(),
|
|
795
827
|
actor: { kind: 'agent', id: auth.accountId },
|
|
796
828
|
}, request.root);
|
|
797
829
|
return ok({ data: { deleted: true } });
|
|
@@ -843,7 +875,7 @@ const CHECK_AFTER_SECS = 1;
|
|
|
843
875
|
const isVideoCategory = (category: string): boolean => VIDEO_CATEGORIES.has(category);
|
|
844
876
|
|
|
845
877
|
function nowSeconds(request: XRequest): number {
|
|
846
|
-
return Math.floor(Date.parse(request.occurredAt ??
|
|
878
|
+
return Math.floor(Date.parse(request.occurredAt ?? worldNow()) / 1000);
|
|
847
879
|
}
|
|
848
880
|
|
|
849
881
|
/** A field of the upload body, from the multipart form or the JSON object. */
|
|
@@ -1000,7 +1032,7 @@ async function uploadMedia(request: XRequest, all: Resource[], auth: Authorized,
|
|
|
1000
1032
|
const opened: XMediaRecord = {
|
|
1001
1033
|
id, media_key: mediaKeyFor(category, id), account_id: auth.accountId, media_category: category, media_type: 'application/octet-stream', state: 'initialized',
|
|
1002
1034
|
...(owners.length > 0 ? { additional_owners: owners } : {}),
|
|
1003
|
-
created_at: request.occurredAt ??
|
|
1035
|
+
created_at: request.occurredAt ?? worldNow(), expires_at: nowSeconds(request) + MEDIA_EXPIRES_AFTER_SECS,
|
|
1004
1036
|
};
|
|
1005
1037
|
// Check before storing: a refused image leaves no bytes behind.
|
|
1006
1038
|
const checked = finishUpload(opened, bytes, '');
|
|
@@ -1039,7 +1071,7 @@ async function initializeUpload(request: XRequest, all: Resource[], auth: Author
|
|
|
1039
1071
|
const record: XMediaRecord = {
|
|
1040
1072
|
id, media_key: mediaKeyFor(category, id), account_id: auth.accountId, media_category: category, media_type: mediaType, state: 'initialized', total_bytes: total,
|
|
1041
1073
|
...(owners.length > 0 ? { additional_owners: owners } : {}),
|
|
1042
|
-
created_at: request.occurredAt ??
|
|
1074
|
+
created_at: request.occurredAt ?? worldNow(), expires_at: nowSeconds(request) + MEDIA_EXPIRES_AFTER_SECS,
|
|
1043
1075
|
};
|
|
1044
1076
|
await writeMediaRecord(record, request.root);
|
|
1045
1077
|
return ok({ data: { id, media_key: record.media_key, expires_after_secs: MEDIA_EXPIRES_AFTER_SECS } });
|
|
@@ -1347,12 +1379,19 @@ async function handleTwinControl(request: XRequest, all: Resource[], path: strin
|
|
|
1347
1379
|
if (typeof payload[key] !== 'string') return invalidRequestProblem({ [key]: [String(payload[key])] }, `A seeded account's \`${key}\` must be a string.`);
|
|
1348
1380
|
profile[key] = payload[key];
|
|
1349
1381
|
}
|
|
1382
|
+
// A photo the person uploaded: X serves it from pbs.twimg.com/profile_images/…, an https URL.
|
|
1383
|
+
if (payload.profile_image_url !== undefined) {
|
|
1384
|
+
if (typeof payload.profile_image_url !== 'string' || !/^https:\/\/\S+$/.test(payload.profile_image_url)) {
|
|
1385
|
+
return invalidRequestProblem({ profile_image_url: [String(payload.profile_image_url)] }, "A seeded account's `profile_image_url` must be an https URL.");
|
|
1386
|
+
}
|
|
1387
|
+
profile.profile_image_url = payload.profile_image_url;
|
|
1388
|
+
}
|
|
1350
1389
|
for (const key of ['protected', 'verified'] as const) {
|
|
1351
1390
|
if (payload[key] === undefined) continue;
|
|
1352
1391
|
if (typeof payload[key] !== 'boolean') return invalidRequestProblem({ [key]: [String(payload[key])] }, `A seeded account's \`${key}\` must be a boolean.`);
|
|
1353
1392
|
profile[key] = payload[key];
|
|
1354
1393
|
}
|
|
1355
|
-
const createdAt = request.occurredAt ??
|
|
1394
|
+
const createdAt = request.occurredAt ?? worldNow();
|
|
1356
1395
|
await applyTwinWrite(SERVICE, {
|
|
1357
1396
|
operation: 'x.twin.seed_account',
|
|
1358
1397
|
subjectType: 'account',
|
|
@@ -1376,7 +1415,7 @@ async function handleTwinControl(request: XRequest, all: Resource[], path: strin
|
|
|
1376
1415
|
subjectType: 'token',
|
|
1377
1416
|
subjectId: token,
|
|
1378
1417
|
fields: { account_id: accountId, scopes },
|
|
1379
|
-
occurredAt: request.occurredAt ??
|
|
1418
|
+
occurredAt: request.occurredAt ?? worldNow(),
|
|
1380
1419
|
}, request.root);
|
|
1381
1420
|
return ok({ data: { token, account_id: accountId, scopes } });
|
|
1382
1421
|
}
|
|
@@ -1389,7 +1428,7 @@ async function handleTwinControl(request: XRequest, all: Resource[], path: strin
|
|
|
1389
1428
|
return invalidRequestProblem({ author_id: [String(authorId ?? '')] }, 'A seeded post must name an account seeded through /_twin/accounts.');
|
|
1390
1429
|
}
|
|
1391
1430
|
const id = mintSnowflakeId(all);
|
|
1392
|
-
const createdAt = request.occurredAt ??
|
|
1431
|
+
const createdAt = request.occurredAt ?? worldNow();
|
|
1393
1432
|
await applyTwinWrite(SERVICE, {
|
|
1394
1433
|
operation: 'x.twin.seed_post',
|
|
1395
1434
|
subjectType: 'post',
|
|
@@ -1416,7 +1455,7 @@ async function handleTwinControl(request: XRequest, all: Resource[], path: strin
|
|
|
1416
1455
|
subjectType: 'follow',
|
|
1417
1456
|
subjectId: `${followerId}:${followedId}`,
|
|
1418
1457
|
fields: { follower_id: followerId, followed_id: followedId },
|
|
1419
|
-
occurredAt: request.occurredAt ??
|
|
1458
|
+
occurredAt: request.occurredAt ?? worldNow(),
|
|
1420
1459
|
}, request.root);
|
|
1421
1460
|
return ok({ data: { follower_id: followerId, followed_id: followedId } });
|
|
1422
1461
|
}
|
|
@@ -1430,7 +1469,7 @@ async function handleTwinControl(request: XRequest, all: Resource[], path: strin
|
|
|
1430
1469
|
subjectType: 'rate_limit',
|
|
1431
1470
|
subjectId: 'armed',
|
|
1432
1471
|
fields: { armed: payload.armed !== false, reset: resetAt, limit: typeof payload.limit === 'number' ? payload.limit : 200 },
|
|
1433
|
-
occurredAt: request.occurredAt ??
|
|
1472
|
+
occurredAt: request.occurredAt ?? worldNow(),
|
|
1434
1473
|
}, request.root);
|
|
1435
1474
|
return ok({ data: { armed: payload.armed !== false, reset: resetAt } });
|
|
1436
1475
|
}
|
|
@@ -1485,7 +1524,7 @@ export async function handleXTwinRequest(request: XRequest): Promise<XResponse>
|
|
|
1485
1524
|
const refusal = armedRateRefusal(all);
|
|
1486
1525
|
if (refusal) return refusal;
|
|
1487
1526
|
|
|
1488
|
-
const auth = authorize(all, request
|
|
1527
|
+
const auth = authorize(all, request);
|
|
1489
1528
|
if (isResponse(auth)) return auth;
|
|
1490
1529
|
const publicBase = (request.publicBase ?? PBS_BASE).replace(/\/+$/, '');
|
|
1491
1530
|
|