@volter/world-core 3.0.158 → 3.0.160
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/dist/generated/pack-facts.json +134 -4
- package/dist/src/actions.d.ts +9 -1
- package/dist/src/actions.js +13 -1
- package/dist/src/changeset.js +8 -4
- package/dist/src/derived-core.js +92 -5
- package/dist/src/derived-real.js +5 -0
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/people.d.ts +3 -0
- package/dist/src/people.js +5 -0
- package/dist/src/runtime.d.ts +2 -1
- package/dist/src/runtime.js +3 -1
- package/generated/pack-facts.json +134 -4
- package/package.json +1 -1
- package/src/actions.ts +15 -1
- package/src/changeset.ts +6 -3
- package/src/derived-core.ts +82 -5
- package/src/derived-real.ts +4 -0
- package/src/index.ts +1 -1
- package/src/people.ts +6 -0
- package/src/runtime.ts +4 -0
|
@@ -1208,16 +1208,34 @@
|
|
|
1208
1208
|
"resources": [
|
|
1209
1209
|
"client",
|
|
1210
1210
|
"account",
|
|
1211
|
-
"access_token"
|
|
1211
|
+
"access_token",
|
|
1212
|
+
"api_key"
|
|
1212
1213
|
],
|
|
1213
1214
|
"endpointEnvNone": "Google's OAuth libraries read no base-URL variable: the endpoints are the discovery document's, claimed by the host rules.",
|
|
1214
1215
|
"credentialDoor": {
|
|
1215
|
-
"path": "/_twin/
|
|
1216
|
+
"path": "/_twin/credentials",
|
|
1216
1217
|
"body": {
|
|
1217
|
-
"client_email": "world@world-project.iam.gserviceaccount.com"
|
|
1218
|
+
"client_email": "world@world-project.iam.gserviceaccount.com",
|
|
1219
|
+
"client": {
|
|
1220
|
+
"redirect_uris": [
|
|
1221
|
+
"http://localhost:3000/oauth2callback"
|
|
1222
|
+
],
|
|
1223
|
+
"name": "World App"
|
|
1224
|
+
},
|
|
1225
|
+
"account": {
|
|
1226
|
+
"sub": "100000000000000000001",
|
|
1227
|
+
"email": "person@world.example",
|
|
1228
|
+
"name": "World Person"
|
|
1229
|
+
},
|
|
1230
|
+
"api_key": {
|
|
1231
|
+
"name": "API key 1"
|
|
1232
|
+
}
|
|
1218
1233
|
},
|
|
1219
1234
|
"fill": {
|
|
1220
|
-
"GOOGLE_APPLICATION_CREDENTIALS": "file:credentials"
|
|
1235
|
+
"GOOGLE_APPLICATION_CREDENTIALS": "file:credentials",
|
|
1236
|
+
"GOOGLE_CLIENT_ID": "client_id",
|
|
1237
|
+
"GOOGLE_CLIENT_SECRET": "client_secret",
|
|
1238
|
+
"YOUTUBE_API_KEY": "api_key"
|
|
1221
1239
|
}
|
|
1222
1240
|
},
|
|
1223
1241
|
"serveExport": "createGoogleoauthTwinServer"
|
|
@@ -1370,6 +1388,53 @@
|
|
|
1370
1388
|
},
|
|
1371
1389
|
"serveExport": "createHuggingfaceTwinServer"
|
|
1372
1390
|
},
|
|
1391
|
+
"instagram": {
|
|
1392
|
+
"adoption": {
|
|
1393
|
+
"sdks": [],
|
|
1394
|
+
"envStems": [
|
|
1395
|
+
"INSTAGRAM"
|
|
1396
|
+
]
|
|
1397
|
+
},
|
|
1398
|
+
"hosts": [
|
|
1399
|
+
{
|
|
1400
|
+
"host": "graph.instagram.com"
|
|
1401
|
+
},
|
|
1402
|
+
{
|
|
1403
|
+
"host": "api.instagram.com"
|
|
1404
|
+
},
|
|
1405
|
+
{
|
|
1406
|
+
"host": "www.instagram.com",
|
|
1407
|
+
"pathPattern": "^/oauth/authorize"
|
|
1408
|
+
},
|
|
1409
|
+
{
|
|
1410
|
+
"host": "scontent.cdninstagram.com"
|
|
1411
|
+
}
|
|
1412
|
+
],
|
|
1413
|
+
"archetype": "crud",
|
|
1414
|
+
"protocol": {
|
|
1415
|
+
"declared": "3",
|
|
1416
|
+
"major": 3,
|
|
1417
|
+
"standing": "current"
|
|
1418
|
+
},
|
|
1419
|
+
"transport": "rest",
|
|
1420
|
+
"resources": [
|
|
1421
|
+
"container",
|
|
1422
|
+
"media",
|
|
1423
|
+
"comment"
|
|
1424
|
+
],
|
|
1425
|
+
"endpointEnvNone": "an app calls graph.instagram.com and api.instagram.com and sends people to www.instagram.com; the World claims the hosts",
|
|
1426
|
+
"credentialDoor": {
|
|
1427
|
+
"path": "/_twin/app-credentials",
|
|
1428
|
+
"body": {},
|
|
1429
|
+
"fill": {
|
|
1430
|
+
"INSTAGRAM_APP_ID": "app_id",
|
|
1431
|
+
"INSTAGRAM_APP_SECRET": "app_secret",
|
|
1432
|
+
"INSTAGRAM_ACCESS_TOKEN": "access_token",
|
|
1433
|
+
"INSTAGRAM_USER_ID": "user_id"
|
|
1434
|
+
}
|
|
1435
|
+
},
|
|
1436
|
+
"serveExport": "createInstagramTwinServer"
|
|
1437
|
+
},
|
|
1373
1438
|
"intercom": {
|
|
1374
1439
|
"adoption": {
|
|
1375
1440
|
"sdks": [
|
|
@@ -3383,6 +3448,71 @@
|
|
|
3383
3448
|
}
|
|
3384
3449
|
},
|
|
3385
3450
|
"serveExport": "createXaiTwinServer"
|
|
3451
|
+
},
|
|
3452
|
+
"youtube": {
|
|
3453
|
+
"adoption": {
|
|
3454
|
+
"sdks": [
|
|
3455
|
+
"@googleapis/youtube"
|
|
3456
|
+
],
|
|
3457
|
+
"envStems": [
|
|
3458
|
+
"YOUTUBE"
|
|
3459
|
+
]
|
|
3460
|
+
},
|
|
3461
|
+
"hosts": [
|
|
3462
|
+
{
|
|
3463
|
+
"host": "www.googleapis.com",
|
|
3464
|
+
"pathPattern": "^/(upload/|resumable/upload/)?youtube/v3(/|$)"
|
|
3465
|
+
},
|
|
3466
|
+
{
|
|
3467
|
+
"host": "youtube.googleapis.com",
|
|
3468
|
+
"pathPattern": "^/(upload/|resumable/upload/)?youtube/v3(/|$)|^/_twin/"
|
|
3469
|
+
}
|
|
3470
|
+
],
|
|
3471
|
+
"archetype": "crud",
|
|
3472
|
+
"protocol": {
|
|
3473
|
+
"declared": "3",
|
|
3474
|
+
"major": 3,
|
|
3475
|
+
"standing": "current"
|
|
3476
|
+
},
|
|
3477
|
+
"transport": "rest",
|
|
3478
|
+
"resources": [
|
|
3479
|
+
"channel",
|
|
3480
|
+
"video",
|
|
3481
|
+
"liveBroadcast",
|
|
3482
|
+
"liveStream",
|
|
3483
|
+
"liveChatMessage",
|
|
3484
|
+
"commentThread",
|
|
3485
|
+
"comment",
|
|
3486
|
+
"_upload_session",
|
|
3487
|
+
"_live_chat",
|
|
3488
|
+
"_comment_ban",
|
|
3489
|
+
"_live",
|
|
3490
|
+
"_live_segment"
|
|
3491
|
+
],
|
|
3492
|
+
"endpointEnvNone": "Google's generated clients take rootUrl in code (googleapis: https://youtube.googleapis.com/); the World routes the declared YouTube paths on both API hosts",
|
|
3493
|
+
"credentialDoor": {
|
|
3494
|
+
"path": "/_twin/channels",
|
|
3495
|
+
"body": {
|
|
3496
|
+
"sub": "100000000000000000001",
|
|
3497
|
+
"title": "World Channel",
|
|
3498
|
+
"handle": "worldchannel"
|
|
3499
|
+
},
|
|
3500
|
+
"fill": {
|
|
3501
|
+
"YOUTUBE_CHANNEL_ID": "id"
|
|
3502
|
+
}
|
|
3503
|
+
},
|
|
3504
|
+
"credentialIssuer": "googleoauth",
|
|
3505
|
+
"sidePorts": [
|
|
3506
|
+
{
|
|
3507
|
+
"name": "ingest",
|
|
3508
|
+
"option": "ingestPort",
|
|
3509
|
+
"flag": "--ingest-port",
|
|
3510
|
+
"scheme": "rtmp",
|
|
3511
|
+
"path": "/live2",
|
|
3512
|
+
"note": "YouTube's RTMP ingest (<ingestionAddress>/<streamName>): an encoder publishes a liveStream's video here"
|
|
3513
|
+
}
|
|
3514
|
+
],
|
|
3515
|
+
"serveExport": "createYoutubeTwinServer"
|
|
3386
3516
|
}
|
|
3387
3517
|
},
|
|
3388
3518
|
"undeclared": []
|
package/dist/src/actions.d.ts
CHANGED
|
@@ -309,8 +309,16 @@ export declare function revertAction(opts: {
|
|
|
309
309
|
export declare function pendingActions(service: string, root?: string): TwinAction[];
|
|
310
310
|
/** A twin's OWN bookkeeping in its action log — a subject type beginning with `_` (stripe's
|
|
311
311
|
* `_idempotency` store, for one): part of the overlay the twin serves from, never a change the
|
|
312
|
-
* app made. It is not in the log a user reads,
|
|
312
|
+
* app made. It is not pushed or deployed. It is not in the log a user reads, a diff or a changeset either, except the
|
|
313
|
+
* World's people (`isWorldPerson`). */
|
|
313
314
|
export declare function isTwinBookkeeping(action: Pick<TwinAction, 'subject'>): boolean;
|
|
315
|
+
/** A person a twin's pages sign in (`_person`, people.ts): bookkeeping to the kernel, and the one bookkeeping the log
|
|
316
|
+
* a user reads, the diff and every changeset carry, so an account replayed into another World signs in there. A
|
|
317
|
+
* replay writes its committed row (the email, the password's hash and what the sign-up keeps), never the sign-up.
|
|
318
|
+
* ADR 0025. */
|
|
319
|
+
export declare function isWorldPerson(action: Pick<TwinAction, 'subject'>): boolean;
|
|
320
|
+
/** Whether an action is in the World's history as a user reads it, diffs it and cuts changesets from it. */
|
|
321
|
+
export declare function inWorldHistory(action: Pick<TwinAction, 'subject'>): boolean;
|
|
314
322
|
/** The PUSHABLE suffix — `log origin..HEAD` as the push arm, the pending door, plans and the
|
|
315
323
|
* unpushed count read it: the overlay minus quarantined rows (an import is a copy, not a
|
|
316
324
|
* decision; releasing a quarantined row is an explicit act, never a scheduler's) and minus the
|
package/dist/src/actions.js
CHANGED
|
@@ -552,10 +552,22 @@ export function pendingActions(service, root) {
|
|
|
552
552
|
}
|
|
553
553
|
/** A twin's OWN bookkeeping in its action log — a subject type beginning with `_` (stripe's
|
|
554
554
|
* `_idempotency` store, for one): part of the overlay the twin serves from, never a change the
|
|
555
|
-
* app made. It is not in the log a user reads,
|
|
555
|
+
* app made. It is not pushed or deployed. It is not in the log a user reads, a diff or a changeset either, except the
|
|
556
|
+
* World's people (`isWorldPerson`). */
|
|
556
557
|
export function isTwinBookkeeping(action) {
|
|
557
558
|
return action.subject.type.startsWith('_');
|
|
558
559
|
}
|
|
560
|
+
/** A person a twin's pages sign in (`_person`, people.ts): bookkeeping to the kernel, and the one bookkeeping the log
|
|
561
|
+
* a user reads, the diff and every changeset carry, so an account replayed into another World signs in there. A
|
|
562
|
+
* replay writes its committed row (the email, the password's hash and what the sign-up keeps), never the sign-up.
|
|
563
|
+
* ADR 0025. */
|
|
564
|
+
export function isWorldPerson(action) {
|
|
565
|
+
return action.subject.type === '_person';
|
|
566
|
+
}
|
|
567
|
+
/** Whether an action is in the World's history as a user reads it, diffs it and cuts changesets from it. */
|
|
568
|
+
export function inWorldHistory(action) {
|
|
569
|
+
return !isTwinBookkeeping(action) || isWorldPerson(action);
|
|
570
|
+
}
|
|
559
571
|
/** The PUSHABLE suffix — `log origin..HEAD` as the push arm, the pending door, plans and the
|
|
560
572
|
* unpushed count read it: the overlay minus quarantined rows (an import is a copy, not a
|
|
561
573
|
* decision; releasing a quarantined row is an explicit act, never a scheduler's) and minus the
|
package/dist/src/changeset.js
CHANGED
|
@@ -58,7 +58,7 @@ import { worldPaths } from "./storage.js";
|
|
|
58
58
|
// discovery and the CLI verbs; the state-level logic lives here, beside the ledgers it reads.
|
|
59
59
|
import { createHash } from 'node:crypto';
|
|
60
60
|
import { aliasesFrom, readTreeMapsAt, wholeLog } from "./log.js";
|
|
61
|
-
import { appendActionIfAbsent, checkPrecondition, listActions, projectedPreconditionValue, projectResources,
|
|
61
|
+
import { appendActionIfAbsent, checkPrecondition, listActions, projectedPreconditionValue, projectResources, inWorldHistory, isWorldPerson } from "./actions.js";
|
|
62
62
|
import { canonicalJson, diffSubjectFields, hashFieldValue } from "./hash.js";
|
|
63
63
|
import { applyTwinWrite } from "./serve.js";
|
|
64
64
|
export const MARKER_KIND = 'volter.world.marker.v1';
|
|
@@ -133,8 +133,8 @@ export function diffLedgers(opts) {
|
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
135
|
rows.slice(from).forEach((action, offset) => {
|
|
136
|
-
if (
|
|
137
|
-
return; // the twin's own store, never a change the app made
|
|
136
|
+
if (!inWorldHistory(action))
|
|
137
|
+
return; // the twin's own store, never a change the app made (a person is the World's: ADR 0025)
|
|
138
138
|
ordered.push({ entry: { service: ledger.service, stateService: ledger.stateService, action }, ledgerIndex, rowIndex: from + offset });
|
|
139
139
|
});
|
|
140
140
|
});
|
|
@@ -181,10 +181,14 @@ export function describeActionFields(ledgers, actions) {
|
|
|
181
181
|
continue;
|
|
182
182
|
const subject = trees.get(positions.get(action.id))?.get(`${action.subject.type}:${action.subject.id}`);
|
|
183
183
|
const fields = subject?.fields ?? {};
|
|
184
|
-
|
|
184
|
+
const changes = diffSubjectFields({
|
|
185
185
|
subject: action.subject, fields, fieldHashes: Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, hashFieldValue(v)])),
|
|
186
186
|
latestEventId: '', updatedAt: '',
|
|
187
187
|
}, action.fields);
|
|
188
|
+
// a person's password hash is shown as set, never its value (ADR 0025)
|
|
189
|
+
if (isWorldPerson(action) && changes.password_sha256)
|
|
190
|
+
changes.password_sha256 = { before: changes.password_sha256.before === undefined ? undefined : '(set)', after: '(set)' };
|
|
191
|
+
fieldChanges[fieldDiffKey(service, stateService, action.id)] = changes;
|
|
188
192
|
}
|
|
189
193
|
}
|
|
190
194
|
return fieldChanges;
|
package/dist/src/derived-core.js
CHANGED
|
@@ -696,6 +696,81 @@ async function keptFiles(m, call, root) {
|
|
|
696
696
|
function provisioningInput(call) {
|
|
697
697
|
return call.request.headers.get('x-volter-world-activity') === 'provisioning' ? { input: { activity: 'provisioning' } } : {};
|
|
698
698
|
}
|
|
699
|
+
/** Whether a field names a credential: a password under any name the packs' doors and screens give it (`password`,
|
|
700
|
+
* `passwd`, `pw`, `pass`, `session_password`, `user[password]`…; a screen names its own field, world-ui's SignIn
|
|
701
|
+
* `password.name`, so the rule reads the name, not a list), or a secret, an API key, an access, refresh or id token, a
|
|
702
|
+
* PKCE verifier or a private key, alone or as the name's last part. ADR 0025. */
|
|
703
|
+
const credentialKey = (key) => {
|
|
704
|
+
const k = key.toLowerCase();
|
|
705
|
+
return k.includes('password') || k.includes('passwd') || /(^|[[_.-])(pw|pass|secret|client_secret|api_?key|apikey|access_token|refresh_token|id_token|code_verifier|private_key)\]?$/.test(k);
|
|
706
|
+
};
|
|
707
|
+
/** A request header that carries a credential: never recorded, whatever a manifest names (the executor sends the real
|
|
708
|
+
* system's own from custody). */
|
|
709
|
+
const credentialHeader = (header) => {
|
|
710
|
+
const h = header.toLowerCase();
|
|
711
|
+
return h === 'authorization' || h === 'proxy-authorization' || h === 'cookie' || /(^|-)api-?key$/.test(h) || /token|secret|password|passwd/.test(h);
|
|
712
|
+
};
|
|
713
|
+
/** A form's text as a `<form method="post">` sends it (`a=1&password=…`), told apart from other text. */
|
|
714
|
+
/** A posted form's fields without its credentials, or the text unchanged, byte for byte, when it carries none. */
|
|
715
|
+
function formWithoutCredentials(text, removed) {
|
|
716
|
+
const form = new URLSearchParams(text);
|
|
717
|
+
const credentials = [...new Set(form.keys())].filter(credentialKey);
|
|
718
|
+
if (!credentials.length)
|
|
719
|
+
return text;
|
|
720
|
+
for (const key of credentials) {
|
|
721
|
+
removed.add(key);
|
|
722
|
+
form.delete(key);
|
|
723
|
+
}
|
|
724
|
+
return form.toString();
|
|
725
|
+
}
|
|
726
|
+
/** A multipart form's parts without one named for a credential, or the text unchanged when none is. */
|
|
727
|
+
function multipartWithoutCredentials(text, boundary, removed) {
|
|
728
|
+
const parts = text.split(`--${boundary}`);
|
|
729
|
+
const kept = parts.filter((part) => {
|
|
730
|
+
const name = /name="([^"]*)"/.exec(part)?.[1] ?? '';
|
|
731
|
+
if (!credentialKey(name))
|
|
732
|
+
return true;
|
|
733
|
+
removed.add(name);
|
|
734
|
+
return false;
|
|
735
|
+
});
|
|
736
|
+
return kept.length === parts.length ? text : kept.join(`--${boundary}`);
|
|
737
|
+
}
|
|
738
|
+
/** A recorded request's body or query without its credentials, named in `removed`: fields named for one are taken out of
|
|
739
|
+
* a JSON value at any depth. A body that is a posted form (its content type says so) is read as one: its text, as it
|
|
740
|
+
* came or kept as `{ $text }`, loses its credential fields (re-encoded with URLSearchParams, the others in order), and a
|
|
741
|
+
* multipart form loses a part named for one. Any other string, at any depth, is left as it was: a URL with a query in
|
|
742
|
+
* a JSON body (a checkout's success_url) is a value, not a form. */
|
|
743
|
+
function withoutCredentials(value, contentType = '', removed = new Set()) {
|
|
744
|
+
const form = /application\/x-www-form-urlencoded/i.test(contentType);
|
|
745
|
+
const boundary = /multipart\/form-data;.*boundary="?([^";]+)"?/i.exec(contentType)?.[1];
|
|
746
|
+
if (typeof value === 'string')
|
|
747
|
+
return form ? formWithoutCredentials(value, removed) : boundary ? multipartWithoutCredentials(value, boundary, removed) : value;
|
|
748
|
+
// (a body that came as text is kept as { $text }: the form is in it)
|
|
749
|
+
if (form && value && typeof value === 'object' && typeof value.$text === 'string') {
|
|
750
|
+
return { ...value, $text: formWithoutCredentials(value.$text, removed) };
|
|
751
|
+
}
|
|
752
|
+
return withoutCredentialFields(value, removed);
|
|
753
|
+
}
|
|
754
|
+
/** A JSON value without the fields named for a credential, at any depth; every other value is kept as it was. */
|
|
755
|
+
function withoutCredentialFields(value, removed) {
|
|
756
|
+
if (Array.isArray(value))
|
|
757
|
+
return value.map((v) => withoutCredentialFields(v, removed));
|
|
758
|
+
if (!value || typeof value !== 'object')
|
|
759
|
+
return value;
|
|
760
|
+
const proto = Object.getPrototypeOf(value);
|
|
761
|
+
if (proto !== Object.prototype && proto !== null)
|
|
762
|
+
return value;
|
|
763
|
+
return Object.fromEntries(Object.entries(value).filter(([key]) => {
|
|
764
|
+
if (!credentialKey(key))
|
|
765
|
+
return true;
|
|
766
|
+
removed.add(key);
|
|
767
|
+
return false;
|
|
768
|
+
}).map(([key, v]) => [key, withoutCredentialFields(v, removed)]));
|
|
769
|
+
}
|
|
770
|
+
/** What a write records of its request, with no credential in it on any branch (ADR 0025): the body (JSON, form text,
|
|
771
|
+
* multipart), the declared query and the GraphQL variables lose their credential fields, and a header that carries one
|
|
772
|
+
* is not recorded. A write whose body, query or a header lost a field names it in `withheld`, so a deploy, which
|
|
773
|
+
* sends the recorded request again, refuses it rather than sending it without (derived-real.ts performOf). */
|
|
699
774
|
function recordedInput(m, call, merged) {
|
|
700
775
|
const id = call.operation.id;
|
|
701
776
|
const sent = sentOf.get(call);
|
|
@@ -704,18 +779,30 @@ function recordedInput(m, call, merged) {
|
|
|
704
779
|
const body = sent?.body ?? merged;
|
|
705
780
|
const safeBody = door?.redactBody?.length && body && typeof body === 'object' && !Array.isArray(body)
|
|
706
781
|
? Object.fromEntries(Object.entries(body).filter(([key]) => !door.redactBody.includes(key))) : body;
|
|
707
|
-
const
|
|
782
|
+
const withheld = new Set();
|
|
783
|
+
// (a header a manifest names that carries a credential is named too: a deploy would otherwise send the write without it)
|
|
784
|
+
const headers = (sentHeaders) => Object.fromEntries(Object.entries(sentHeaders).filter(([h]) => {
|
|
785
|
+
if (!credentialHeader(h))
|
|
786
|
+
return true;
|
|
787
|
+
withheld.add(`header:${h.toLowerCase()}`);
|
|
788
|
+
return false;
|
|
789
|
+
}));
|
|
790
|
+
const asSent = sent
|
|
791
|
+
? { ...activity, path: sent.path, query: withoutCredentials(sent.query, '', withheld), body: withoutCredentials(safeBody, sent.type ?? '', withheld), headers: headers(sent.headers), ...(sent.lane ? { lane: sent.lane } : {}), ...(sent.requestId ? { requestId: sent.requestId } : {}), ...(sent.type ? { contentType: sent.type } : {}) }
|
|
792
|
+
: { ...activity, path: call.params, body: withoutCredentials(safeBody, '', withheld) };
|
|
793
|
+
const named = () => (withheld.size ? { withheld: [...withheld].sort() } : {});
|
|
708
794
|
if (id === 'graphql') {
|
|
709
795
|
const b = (sent?.body ?? {});
|
|
710
|
-
|
|
796
|
+
const variables = withoutCredentials(b.variables ?? {}, '', withheld);
|
|
797
|
+
return { ...activity, graphql: { query: b.query, variables, ...(b.operationName ? { operationName: b.operationName } : {}) }, ...(sent?.lane ? { lane: sent.lane } : {}), headers: headers(sent?.headers ?? {}), ...named() };
|
|
711
798
|
}
|
|
712
799
|
if (id === 'clock')
|
|
713
800
|
return { clock: true };
|
|
714
801
|
if (id === 'unmatched')
|
|
715
|
-
return { gap: true, ...asSent };
|
|
802
|
+
return { gap: true, ...asSent, ...named() };
|
|
716
803
|
if ((m.doors ?? []).some((d) => d.id === id) || (m.screens ?? []).some((sc) => sc.id === id) || (m.accountSetup ?? []).some((a) => a.operation === id) || id === 'machine-pool' || id.startsWith('socket:'))
|
|
717
|
-
return { door: id, ...asSent };
|
|
718
|
-
return { operationId: id, ...asSent };
|
|
804
|
+
return { door: id, ...asSent, ...named() };
|
|
805
|
+
return { operationId: id, ...asSent, ...named() };
|
|
719
806
|
}
|
|
720
807
|
/** The scope each call is served in (serveCore, contextFor), for the contexts a write opens for its hooks. */
|
|
721
808
|
const scopeOfCall = new WeakMap();
|
package/dist/src/derived-real.js
CHANGED
|
@@ -348,6 +348,11 @@ function performOf(units) {
|
|
|
348
348
|
const unit = unitOf(units, input);
|
|
349
349
|
if (!unit)
|
|
350
350
|
throw new RefusedWriteError('vendor', `the write records no operation of the vendor's surface (${String(input.operationId ?? 'none')})`, first.id);
|
|
351
|
+
// A credential the request carried was not kept (no World records one in plaintext: ADR 0025), so the request as
|
|
352
|
+
// recorded is not the request that was made; it is refused, never sent without it.
|
|
353
|
+
const withheld = Array.isArray(input.withheld) ? input.withheld.map(String) : [];
|
|
354
|
+
if (withheld.length)
|
|
355
|
+
throw new RefusedWriteError('vendor', `the write's ${withheld.join(', ')} ${withheld.length === 1 ? 'was' : 'were'} not kept (no World records a credential in plaintext), so it is not sent again; make it at the vendor`, first.id);
|
|
351
356
|
const { manifest: m, surface } = unit;
|
|
352
357
|
// ids the World minted are sent as the vendor's: every stored type's adopted aliases, tried in turn
|
|
353
358
|
const types = [...new Set(Object.keys(m.resources).map((r) => storedType(m, r)))];
|
package/dist/src/index.d.ts
CHANGED
|
@@ -53,7 +53,7 @@ export { blobDigest } from './blob-store.js';
|
|
|
53
53
|
export { gunzip, gzip } from './bytes.js';
|
|
54
54
|
export { ownFields } from './log.js';
|
|
55
55
|
export type { TwinResource } from './serve.js';
|
|
56
|
-
export { passwordHash, personOf, personWith, recordPerson, type Person } from './people.js';
|
|
56
|
+
export { passwordHash, personOf, personWith, recordPerson, shownPersonFields, type Person } from './people.js';
|
|
57
57
|
export { EngineDatabaseError, EngineUnavailableError, quoteIdent } from './managed-database.js';
|
|
58
58
|
export type { Batch as EngineBatch, ManagedDatabase, Statement as EngineStatement, StatementResult as EngineStatementResult } from './managed-database.js';
|
|
59
59
|
export type { Mail, SmtpRoute } from './smtp.js';
|
package/dist/src/index.js
CHANGED
|
@@ -44,7 +44,7 @@ export { gunzip, gzip } from "./bytes.js";
|
|
|
44
44
|
// a row's own fields, where the tree's reserved ones (id, type, updatedAt) shadow the vendor's of the same name
|
|
45
45
|
export { ownFields } from "./log.js";
|
|
46
46
|
// the people a vendor's pages sign in (a `_person` row with its password's hash)
|
|
47
|
-
export { passwordHash, personOf, personWith, recordPerson } from "./people.js";
|
|
47
|
+
export { passwordHash, personOf, personWith, recordPerson, shownPersonFields } from "./people.js";
|
|
48
48
|
// the World's managed Postgres a handler reaches as `ctx.engine` (managed-database.ts), and a mail's route (`ctx.mail`)
|
|
49
49
|
export { EngineDatabaseError, EngineUnavailableError, quoteIdent } from "./managed-database.js";
|
|
50
50
|
// S3's wire (contract "S3's wire is a kernel library too") and the git plane (contract "The git plane is a kernel library")
|
package/dist/src/people.d.ts
CHANGED
|
@@ -9,5 +9,8 @@ export declare const passwordHash: (email: string, password: string) => string;
|
|
|
9
9
|
export declare function recordPerson(ctx: HandlerContext, email: string, password: string, fields?: Record<string, unknown>): Promise<Person>;
|
|
10
10
|
/** The person with this email, or undefined. */
|
|
11
11
|
export declare const personOf: (ctx: HandlerContext, email: string) => Person | undefined;
|
|
12
|
+
/** A person's fields as a reader shows them (the CLI's log and diff, a diff's field changes): the password's hash as
|
|
13
|
+
* `(set)`, never its value. A changeset keeps the hash, which its replay writes (ADR 0025). */
|
|
14
|
+
export declare function shownPersonFields(fields: Record<string, unknown>): Record<string, unknown>;
|
|
12
15
|
/** The person whose email and password these are, or undefined. */
|
|
13
16
|
export declare function personWith(ctx: HandlerContext, email: string, password: string): Person | undefined;
|
package/dist/src/people.js
CHANGED
|
@@ -11,6 +11,11 @@ export async function recordPerson(ctx, email, password, fields = {}) {
|
|
|
11
11
|
}
|
|
12
12
|
/** The person with this email, or undefined. */
|
|
13
13
|
export const personOf = (ctx, email) => ctx.rowsRaw(PERSON).find((p) => p.email === email.trim().toLowerCase());
|
|
14
|
+
/** A person's fields as a reader shows them (the CLI's log and diff, a diff's field changes): the password's hash as
|
|
15
|
+
* `(set)`, never its value. A changeset keeps the hash, which its replay writes (ADR 0025). */
|
|
16
|
+
export function shownPersonFields(fields) {
|
|
17
|
+
return 'password_sha256' in fields ? { ...fields, password_sha256: '(set)' } : fields;
|
|
18
|
+
}
|
|
14
19
|
/** The person whose email and password these are, or undefined. */
|
|
15
20
|
export function personWith(ctx, email, password) {
|
|
16
21
|
const person = personOf(ctx, email);
|
package/dist/src/runtime.d.ts
CHANGED
|
@@ -44,7 +44,8 @@ export { createTwinProxy } from './proxy.js';
|
|
|
44
44
|
export type { TwinProxy, TwinProxyOptions, VendorRoute } from './proxy.js';
|
|
45
45
|
export { forkTwin, isFork, readForkMeta, } from './fork.js';
|
|
46
46
|
export type { ForkMeta } from './fork.js';
|
|
47
|
-
export { appendAction, appendTransactionCommit, checkPrecondition, confirmAction, listActions, listTransactionCommits, pendingActions, pushablePendingActions, isTwinBookkeeping, pendingTransactionCommits, projectResources, projectOwnerResources, OwnerStoreAmbiguousError, revertAction, TwinActionPreconditionError, subjectAliases, resolveSubjectId, runWithCorrelationId, currentCorrelationId } from './actions.js';
|
|
47
|
+
export { appendAction, appendTransactionCommit, checkPrecondition, confirmAction, listActions, listTransactionCommits, pendingActions, pushablePendingActions, isTwinBookkeeping, isWorldPerson, inWorldHistory, pendingTransactionCommits, projectResources, projectOwnerResources, OwnerStoreAmbiguousError, revertAction, TwinActionPreconditionError, subjectAliases, resolveSubjectId, runWithCorrelationId, currentCorrelationId } from './actions.js';
|
|
48
|
+
export { shownPersonFields } from './people.js';
|
|
48
49
|
export type { ActionProjection, ProjectedDelivery, ProjectedResource, ProjectedResourcePatch, ProjectedResourceRef, TwinAction, TwinActionOp, TwinActionPrecondition, TwinActionPreconditionOp, TwinActionRevertSpec, TwinTransactionCommit, TwinTransactionCommitOp, TwinTransactionPrecondition, TwinTransactionRevertSpec, } from './actions.js';
|
|
49
50
|
export { approveChangeset, assertMarkerBelongsTo, assertSafeChangesetName, assertValidVerifiers, buildChangeset, captureMarker, changesetContentHash, changesetHashMatches, changesetReadiness, approverOf, CHANGESET_KIND, diffLedgers, describeActionFields, fieldDiffKey, formatApplication, formatChangeset, formatRebaseReport, narrateActions, narrationDrift, rebaseChangeset, formatChangesetStatus, formatLedgerDelta, formatReplayReport, formatVerification, MARKER_KIND, normalizeChangeset, parseVerifierExpression, replayChangeset, runChangesetVerifiers, summarizeByVendor, twinWriteShape, withApplication, withVerification, worldBootMarker, WORLD_BOOT_MARKER_ID, } from './changeset.js';
|
|
50
51
|
export type { ApplyReceipt, RebaseActionResult, RebaseReport, RebaseTarget, Changeset, ChangesetAction, ChangesetApplication, CompensationEntry, ChangesetApproval, ChangesetReadiness, ChangesetVerification, ChangesetVerifier, ChangesetVerifierResult, LedgerDelta, LedgerPosition, LedgerRef, ReplayActionResult, ReplayReport, ReplayTarget, VendorSummary, WorldMarker, } from './changeset.js';
|
package/dist/src/runtime.js
CHANGED
|
@@ -58,7 +58,9 @@ export { FsBlobStore, MemoryBlobStore, blobDigest, getActiveBlobStore, setActive
|
|
|
58
58
|
export { applyTwinWrite, applyTwinWrites, applyTwinWriteAtomic, createTwinServer, CALLER_HEADER, callerForHeader, callerFromHeader, journalTwinRequest, readTwinRequestJournalFrom, twinRequestJournalFailures, twinRequestCredentials, readTwinRequestJournal, recordedInput, resolveTwinRead, twinRequestJournalEnabled, twinRequestJournalPath, twinResources, } from "./serve.js";
|
|
59
59
|
export { createTwinProxy } from "./proxy.js";
|
|
60
60
|
export { forkTwin, isFork, readForkMeta, } from "./fork.js";
|
|
61
|
-
export { appendAction, appendTransactionCommit, checkPrecondition, confirmAction, listActions, listTransactionCommits, pendingActions, pushablePendingActions, isTwinBookkeeping, pendingTransactionCommits, projectResources, projectOwnerResources, OwnerStoreAmbiguousError, revertAction, TwinActionPreconditionError, subjectAliases, resolveSubjectId, runWithCorrelationId, currentCorrelationId } from "./actions.js";
|
|
61
|
+
export { appendAction, appendTransactionCommit, checkPrecondition, confirmAction, listActions, listTransactionCommits, pendingActions, pushablePendingActions, isTwinBookkeeping, isWorldPerson, inWorldHistory, pendingTransactionCommits, projectResources, projectOwnerResources, OwnerStoreAmbiguousError, revertAction, TwinActionPreconditionError, subjectAliases, resolveSubjectId, runWithCorrelationId, currentCorrelationId } from "./actions.js";
|
|
62
|
+
// a person as a reader shows it, its password's hash as (set) (ADR 0025)
|
|
63
|
+
export { shownPersonFields } from "./people.js";
|
|
62
64
|
// ── Operator control plane (R19/R20): remote refs, queue lifecycle, push ledger,
|
|
63
65
|
// apply leases, plan, status. (the twins architecture notes)
|
|
64
66
|
// The CHANGESET primitive + the ledger DIFF (docs/concepts/the-model.md v0) — "commit" and
|
|
@@ -1208,16 +1208,34 @@
|
|
|
1208
1208
|
"resources": [
|
|
1209
1209
|
"client",
|
|
1210
1210
|
"account",
|
|
1211
|
-
"access_token"
|
|
1211
|
+
"access_token",
|
|
1212
|
+
"api_key"
|
|
1212
1213
|
],
|
|
1213
1214
|
"endpointEnvNone": "Google's OAuth libraries read no base-URL variable: the endpoints are the discovery document's, claimed by the host rules.",
|
|
1214
1215
|
"credentialDoor": {
|
|
1215
|
-
"path": "/_twin/
|
|
1216
|
+
"path": "/_twin/credentials",
|
|
1216
1217
|
"body": {
|
|
1217
|
-
"client_email": "world@world-project.iam.gserviceaccount.com"
|
|
1218
|
+
"client_email": "world@world-project.iam.gserviceaccount.com",
|
|
1219
|
+
"client": {
|
|
1220
|
+
"redirect_uris": [
|
|
1221
|
+
"http://localhost:3000/oauth2callback"
|
|
1222
|
+
],
|
|
1223
|
+
"name": "World App"
|
|
1224
|
+
},
|
|
1225
|
+
"account": {
|
|
1226
|
+
"sub": "100000000000000000001",
|
|
1227
|
+
"email": "person@world.example",
|
|
1228
|
+
"name": "World Person"
|
|
1229
|
+
},
|
|
1230
|
+
"api_key": {
|
|
1231
|
+
"name": "API key 1"
|
|
1232
|
+
}
|
|
1218
1233
|
},
|
|
1219
1234
|
"fill": {
|
|
1220
|
-
"GOOGLE_APPLICATION_CREDENTIALS": "file:credentials"
|
|
1235
|
+
"GOOGLE_APPLICATION_CREDENTIALS": "file:credentials",
|
|
1236
|
+
"GOOGLE_CLIENT_ID": "client_id",
|
|
1237
|
+
"GOOGLE_CLIENT_SECRET": "client_secret",
|
|
1238
|
+
"YOUTUBE_API_KEY": "api_key"
|
|
1221
1239
|
}
|
|
1222
1240
|
},
|
|
1223
1241
|
"serveExport": "createGoogleoauthTwinServer"
|
|
@@ -1370,6 +1388,53 @@
|
|
|
1370
1388
|
},
|
|
1371
1389
|
"serveExport": "createHuggingfaceTwinServer"
|
|
1372
1390
|
},
|
|
1391
|
+
"instagram": {
|
|
1392
|
+
"adoption": {
|
|
1393
|
+
"sdks": [],
|
|
1394
|
+
"envStems": [
|
|
1395
|
+
"INSTAGRAM"
|
|
1396
|
+
]
|
|
1397
|
+
},
|
|
1398
|
+
"hosts": [
|
|
1399
|
+
{
|
|
1400
|
+
"host": "graph.instagram.com"
|
|
1401
|
+
},
|
|
1402
|
+
{
|
|
1403
|
+
"host": "api.instagram.com"
|
|
1404
|
+
},
|
|
1405
|
+
{
|
|
1406
|
+
"host": "www.instagram.com",
|
|
1407
|
+
"pathPattern": "^/oauth/authorize"
|
|
1408
|
+
},
|
|
1409
|
+
{
|
|
1410
|
+
"host": "scontent.cdninstagram.com"
|
|
1411
|
+
}
|
|
1412
|
+
],
|
|
1413
|
+
"archetype": "crud",
|
|
1414
|
+
"protocol": {
|
|
1415
|
+
"declared": "3",
|
|
1416
|
+
"major": 3,
|
|
1417
|
+
"standing": "current"
|
|
1418
|
+
},
|
|
1419
|
+
"transport": "rest",
|
|
1420
|
+
"resources": [
|
|
1421
|
+
"container",
|
|
1422
|
+
"media",
|
|
1423
|
+
"comment"
|
|
1424
|
+
],
|
|
1425
|
+
"endpointEnvNone": "an app calls graph.instagram.com and api.instagram.com and sends people to www.instagram.com; the World claims the hosts",
|
|
1426
|
+
"credentialDoor": {
|
|
1427
|
+
"path": "/_twin/app-credentials",
|
|
1428
|
+
"body": {},
|
|
1429
|
+
"fill": {
|
|
1430
|
+
"INSTAGRAM_APP_ID": "app_id",
|
|
1431
|
+
"INSTAGRAM_APP_SECRET": "app_secret",
|
|
1432
|
+
"INSTAGRAM_ACCESS_TOKEN": "access_token",
|
|
1433
|
+
"INSTAGRAM_USER_ID": "user_id"
|
|
1434
|
+
}
|
|
1435
|
+
},
|
|
1436
|
+
"serveExport": "createInstagramTwinServer"
|
|
1437
|
+
},
|
|
1373
1438
|
"intercom": {
|
|
1374
1439
|
"adoption": {
|
|
1375
1440
|
"sdks": [
|
|
@@ -3383,6 +3448,71 @@
|
|
|
3383
3448
|
}
|
|
3384
3449
|
},
|
|
3385
3450
|
"serveExport": "createXaiTwinServer"
|
|
3451
|
+
},
|
|
3452
|
+
"youtube": {
|
|
3453
|
+
"adoption": {
|
|
3454
|
+
"sdks": [
|
|
3455
|
+
"@googleapis/youtube"
|
|
3456
|
+
],
|
|
3457
|
+
"envStems": [
|
|
3458
|
+
"YOUTUBE"
|
|
3459
|
+
]
|
|
3460
|
+
},
|
|
3461
|
+
"hosts": [
|
|
3462
|
+
{
|
|
3463
|
+
"host": "www.googleapis.com",
|
|
3464
|
+
"pathPattern": "^/(upload/|resumable/upload/)?youtube/v3(/|$)"
|
|
3465
|
+
},
|
|
3466
|
+
{
|
|
3467
|
+
"host": "youtube.googleapis.com",
|
|
3468
|
+
"pathPattern": "^/(upload/|resumable/upload/)?youtube/v3(/|$)|^/_twin/"
|
|
3469
|
+
}
|
|
3470
|
+
],
|
|
3471
|
+
"archetype": "crud",
|
|
3472
|
+
"protocol": {
|
|
3473
|
+
"declared": "3",
|
|
3474
|
+
"major": 3,
|
|
3475
|
+
"standing": "current"
|
|
3476
|
+
},
|
|
3477
|
+
"transport": "rest",
|
|
3478
|
+
"resources": [
|
|
3479
|
+
"channel",
|
|
3480
|
+
"video",
|
|
3481
|
+
"liveBroadcast",
|
|
3482
|
+
"liveStream",
|
|
3483
|
+
"liveChatMessage",
|
|
3484
|
+
"commentThread",
|
|
3485
|
+
"comment",
|
|
3486
|
+
"_upload_session",
|
|
3487
|
+
"_live_chat",
|
|
3488
|
+
"_comment_ban",
|
|
3489
|
+
"_live",
|
|
3490
|
+
"_live_segment"
|
|
3491
|
+
],
|
|
3492
|
+
"endpointEnvNone": "Google's generated clients take rootUrl in code (googleapis: https://youtube.googleapis.com/); the World routes the declared YouTube paths on both API hosts",
|
|
3493
|
+
"credentialDoor": {
|
|
3494
|
+
"path": "/_twin/channels",
|
|
3495
|
+
"body": {
|
|
3496
|
+
"sub": "100000000000000000001",
|
|
3497
|
+
"title": "World Channel",
|
|
3498
|
+
"handle": "worldchannel"
|
|
3499
|
+
},
|
|
3500
|
+
"fill": {
|
|
3501
|
+
"YOUTUBE_CHANNEL_ID": "id"
|
|
3502
|
+
}
|
|
3503
|
+
},
|
|
3504
|
+
"credentialIssuer": "googleoauth",
|
|
3505
|
+
"sidePorts": [
|
|
3506
|
+
{
|
|
3507
|
+
"name": "ingest",
|
|
3508
|
+
"option": "ingestPort",
|
|
3509
|
+
"flag": "--ingest-port",
|
|
3510
|
+
"scheme": "rtmp",
|
|
3511
|
+
"path": "/live2",
|
|
3512
|
+
"note": "YouTube's RTMP ingest (<ingestionAddress>/<streamName>): an encoder publishes a liveStream's video here"
|
|
3513
|
+
}
|
|
3514
|
+
],
|
|
3515
|
+
"serveExport": "createYoutubeTwinServer"
|
|
3386
3516
|
}
|
|
3387
3517
|
},
|
|
3388
3518
|
"undeclared": []
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-core",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.160",
|
|
4
4
|
"description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
package/src/actions.ts
CHANGED
|
@@ -680,11 +680,25 @@ export function pendingActions(service: string, root?: string): TwinAction[] {
|
|
|
680
680
|
|
|
681
681
|
/** A twin's OWN bookkeeping in its action log — a subject type beginning with `_` (stripe's
|
|
682
682
|
* `_idempotency` store, for one): part of the overlay the twin serves from, never a change the
|
|
683
|
-
* app made. It is not in the log a user reads,
|
|
683
|
+
* app made. It is not pushed or deployed. It is not in the log a user reads, a diff or a changeset either, except the
|
|
684
|
+
* World's people (`isWorldPerson`). */
|
|
684
685
|
export function isTwinBookkeeping(action: Pick<TwinAction, 'subject'>): boolean {
|
|
685
686
|
return action.subject.type.startsWith('_');
|
|
686
687
|
}
|
|
687
688
|
|
|
689
|
+
/** A person a twin's pages sign in (`_person`, people.ts): bookkeeping to the kernel, and the one bookkeeping the log
|
|
690
|
+
* a user reads, the diff and every changeset carry, so an account replayed into another World signs in there. A
|
|
691
|
+
* replay writes its committed row (the email, the password's hash and what the sign-up keeps), never the sign-up.
|
|
692
|
+
* ADR 0025. */
|
|
693
|
+
export function isWorldPerson(action: Pick<TwinAction, 'subject'>): boolean {
|
|
694
|
+
return action.subject.type === '_person';
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** Whether an action is in the World's history as a user reads it, diffs it and cuts changesets from it. */
|
|
698
|
+
export function inWorldHistory(action: Pick<TwinAction, 'subject'>): boolean {
|
|
699
|
+
return !isTwinBookkeeping(action) || isWorldPerson(action);
|
|
700
|
+
}
|
|
701
|
+
|
|
688
702
|
/** The PUSHABLE suffix — `log origin..HEAD` as the push arm, the pending door, plans and the
|
|
689
703
|
* unpushed count read it: the overlay minus quarantined rows (an import is a copy, not a
|
|
690
704
|
* decision; releasing a quarantined row is an explicit act, never a scheduler's) and minus the
|
package/src/changeset.ts
CHANGED
|
@@ -58,7 +58,7 @@ import { worldPaths } from './storage.ts';
|
|
|
58
58
|
// discovery and the CLI verbs; the state-level logic lives here, beside the ledgers it reads.
|
|
59
59
|
import { createHash } from 'node:crypto';
|
|
60
60
|
import { aliasesFrom, parentEntries, readTreeMapsAt, wholeLog, type Entry } from './log.ts';
|
|
61
|
-
import { appendActionIfAbsent, checkPrecondition, listActions, projectedPreconditionValue, projectResources,
|
|
61
|
+
import { appendActionIfAbsent, checkPrecondition, listActions, projectedPreconditionValue, projectResources, inWorldHistory, isWorldPerson } from './actions.ts';
|
|
62
62
|
import type { TwinActionRevertSpec } from './actions.ts';
|
|
63
63
|
import { touchedSubjects } from './actions.ts';
|
|
64
64
|
import type { PerformAction } from './head.ts';
|
|
@@ -312,7 +312,7 @@ export function diffLedgers(opts: { world: string; ledgers: LedgerRef[]; base: W
|
|
|
312
312
|
}
|
|
313
313
|
}
|
|
314
314
|
rows.slice(from).forEach((action, offset) => {
|
|
315
|
-
if (
|
|
315
|
+
if (!inWorldHistory(action)) return; // the twin's own store, never a change the app made (a person is the World's: ADR 0025)
|
|
316
316
|
ordered.push({ entry: { service: ledger.service, stateService: ledger.stateService, action }, ledgerIndex, rowIndex: from + offset });
|
|
317
317
|
});
|
|
318
318
|
});
|
|
@@ -358,10 +358,13 @@ export function describeActionFields(ledgers: LedgerRef[], actions: ChangesetAct
|
|
|
358
358
|
if (!action.fields) continue;
|
|
359
359
|
const subject = trees.get(positions.get(action.id)!)?.get(`${action.subject.type}:${action.subject.id}`);
|
|
360
360
|
const fields = subject?.fields ?? {};
|
|
361
|
-
|
|
361
|
+
const changes = diffSubjectFields({
|
|
362
362
|
subject: action.subject, fields, fieldHashes: Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, hashFieldValue(v)])),
|
|
363
363
|
latestEventId: '', updatedAt: '',
|
|
364
364
|
}, action.fields);
|
|
365
|
+
// a person's password hash is shown as set, never its value (ADR 0025)
|
|
366
|
+
if (isWorldPerson(action) && changes.password_sha256) changes.password_sha256 = { before: changes.password_sha256.before === undefined ? undefined : '(set)', after: '(set)' };
|
|
367
|
+
fieldChanges[fieldDiffKey(service, stateService, action.id)] = changes;
|
|
365
368
|
}
|
|
366
369
|
}
|
|
367
370
|
return fieldChanges;
|
package/src/derived-core.ts
CHANGED
|
@@ -1201,6 +1201,72 @@ function provisioningInput(call: DerivedCall): { input?: { activity: 'provisioni
|
|
|
1201
1201
|
return call.request.headers.get('x-volter-world-activity') === 'provisioning' ? { input: { activity: 'provisioning' } } : {};
|
|
1202
1202
|
}
|
|
1203
1203
|
|
|
1204
|
+
/** Whether a field names a credential: a password under any name the packs' doors and screens give it (`password`,
|
|
1205
|
+
* `passwd`, `pw`, `pass`, `session_password`, `user[password]`…; a screen names its own field, world-ui's SignIn
|
|
1206
|
+
* `password.name`, so the rule reads the name, not a list), or a secret, an API key, an access, refresh or id token, a
|
|
1207
|
+
* PKCE verifier or a private key, alone or as the name's last part. ADR 0025. */
|
|
1208
|
+
const credentialKey = (key: string): boolean => {
|
|
1209
|
+
const k = key.toLowerCase();
|
|
1210
|
+
return k.includes('password') || k.includes('passwd') || /(^|[[_.-])(pw|pass|secret|client_secret|api_?key|apikey|access_token|refresh_token|id_token|code_verifier|private_key)\]?$/.test(k);
|
|
1211
|
+
};
|
|
1212
|
+
/** A request header that carries a credential: never recorded, whatever a manifest names (the executor sends the real
|
|
1213
|
+
* system's own from custody). */
|
|
1214
|
+
const credentialHeader = (header: string): boolean => {
|
|
1215
|
+
const h = header.toLowerCase();
|
|
1216
|
+
return h === 'authorization' || h === 'proxy-authorization' || h === 'cookie' || /(^|-)api-?key$/.test(h) || /token|secret|password|passwd/.test(h);
|
|
1217
|
+
};
|
|
1218
|
+
/** A form's text as a `<form method="post">` sends it (`a=1&password=…`), told apart from other text. */
|
|
1219
|
+
/** A posted form's fields without its credentials, or the text unchanged, byte for byte, when it carries none. */
|
|
1220
|
+
function formWithoutCredentials(text: string, removed: Set<string>): string {
|
|
1221
|
+
const form = new URLSearchParams(text);
|
|
1222
|
+
const credentials = [...new Set(form.keys())].filter(credentialKey);
|
|
1223
|
+
if (!credentials.length) return text;
|
|
1224
|
+
for (const key of credentials) { removed.add(key); form.delete(key); }
|
|
1225
|
+
return form.toString();
|
|
1226
|
+
}
|
|
1227
|
+
/** A multipart form's parts without one named for a credential, or the text unchanged when none is. */
|
|
1228
|
+
function multipartWithoutCredentials(text: string, boundary: string, removed: Set<string>): string {
|
|
1229
|
+
const parts = text.split(`--${boundary}`);
|
|
1230
|
+
const kept = parts.filter((part) => {
|
|
1231
|
+
const name = /name="([^"]*)"/.exec(part)?.[1] ?? '';
|
|
1232
|
+
if (!credentialKey(name)) return true;
|
|
1233
|
+
removed.add(name);
|
|
1234
|
+
return false;
|
|
1235
|
+
});
|
|
1236
|
+
return kept.length === parts.length ? text : kept.join(`--${boundary}`);
|
|
1237
|
+
}
|
|
1238
|
+
/** A recorded request's body or query without its credentials, named in `removed`: fields named for one are taken out of
|
|
1239
|
+
* a JSON value at any depth. A body that is a posted form (its content type says so) is read as one: its text, as it
|
|
1240
|
+
* came or kept as `{ $text }`, loses its credential fields (re-encoded with URLSearchParams, the others in order), and a
|
|
1241
|
+
* multipart form loses a part named for one. Any other string, at any depth, is left as it was: a URL with a query in
|
|
1242
|
+
* a JSON body (a checkout's success_url) is a value, not a form. */
|
|
1243
|
+
function withoutCredentials(value: unknown, contentType = '', removed = new Set<string>()): unknown {
|
|
1244
|
+
const form = /application\/x-www-form-urlencoded/i.test(contentType);
|
|
1245
|
+
const boundary = /multipart\/form-data;.*boundary="?([^";]+)"?/i.exec(contentType)?.[1];
|
|
1246
|
+
if (typeof value === 'string') return form ? formWithoutCredentials(value, removed) : boundary ? multipartWithoutCredentials(value, boundary, removed) : value;
|
|
1247
|
+
// (a body that came as text is kept as { $text }: the form is in it)
|
|
1248
|
+
if (form && value && typeof value === 'object' && typeof (value as { $text?: unknown }).$text === 'string') {
|
|
1249
|
+
return { ...(value as object), $text: formWithoutCredentials((value as { $text: string }).$text, removed) };
|
|
1250
|
+
}
|
|
1251
|
+
return withoutCredentialFields(value, removed);
|
|
1252
|
+
}
|
|
1253
|
+
/** A JSON value without the fields named for a credential, at any depth; every other value is kept as it was. */
|
|
1254
|
+
function withoutCredentialFields(value: unknown, removed: Set<string>): unknown {
|
|
1255
|
+
if (Array.isArray(value)) return value.map((v) => withoutCredentialFields(v, removed));
|
|
1256
|
+
if (!value || typeof value !== 'object') return value;
|
|
1257
|
+
const proto = Object.getPrototypeOf(value);
|
|
1258
|
+
if (proto !== Object.prototype && proto !== null) return value;
|
|
1259
|
+
return Object.fromEntries(Object.entries(value).filter(([key]) => {
|
|
1260
|
+
if (!credentialKey(key)) return true;
|
|
1261
|
+
removed.add(key);
|
|
1262
|
+
return false;
|
|
1263
|
+
}).map(([key, v]) => [key, withoutCredentialFields(v, removed)]));
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/** What a write records of its request, with no credential in it on any branch (ADR 0025): the body (JSON, form text,
|
|
1267
|
+
* multipart), the declared query and the GraphQL variables lose their credential fields, and a header that carries one
|
|
1268
|
+
* is not recorded. A write whose body, query or a header lost a field names it in `withheld`, so a deploy, which
|
|
1269
|
+
* sends the recorded request again, refuses it rather than sending it without (derived-real.ts performOf). */
|
|
1204
1270
|
function recordedInput(m: DerivedManifest, call: DerivedCall, merged: Record<string, unknown>): Record<string, unknown> {
|
|
1205
1271
|
const id = call.operation.id;
|
|
1206
1272
|
const sent = sentOf.get(call);
|
|
@@ -1209,15 +1275,26 @@ function recordedInput(m: DerivedManifest, call: DerivedCall, merged: Record<str
|
|
|
1209
1275
|
const body = sent?.body ?? merged;
|
|
1210
1276
|
const safeBody = door?.redactBody?.length && body && typeof body === 'object' && !Array.isArray(body)
|
|
1211
1277
|
? Object.fromEntries(Object.entries(body).filter(([key]) => !door.redactBody!.includes(key))) : body;
|
|
1212
|
-
const
|
|
1278
|
+
const withheld = new Set<string>();
|
|
1279
|
+
// (a header a manifest names that carries a credential is named too: a deploy would otherwise send the write without it)
|
|
1280
|
+
const headers = (sentHeaders: Record<string, string>): Record<string, string> => Object.fromEntries(Object.entries(sentHeaders).filter(([h]) => {
|
|
1281
|
+
if (!credentialHeader(h)) return true;
|
|
1282
|
+
withheld.add(`header:${h.toLowerCase()}`);
|
|
1283
|
+
return false;
|
|
1284
|
+
}));
|
|
1285
|
+
const asSent = sent
|
|
1286
|
+
? { ...activity, path: sent.path, query: withoutCredentials(sent.query, '', withheld) as Record<string, string | string[]>, body: withoutCredentials(safeBody, sent.type ?? '', withheld), headers: headers(sent.headers), ...(sent.lane ? { lane: sent.lane } : {}), ...(sent.requestId ? { requestId: sent.requestId } : {}), ...(sent.type ? { contentType: sent.type } : {}) }
|
|
1287
|
+
: { ...activity, path: call.params, body: withoutCredentials(safeBody, '', withheld) };
|
|
1288
|
+
const named = (): { withheld?: string[] } => (withheld.size ? { withheld: [...withheld].sort() } : {});
|
|
1213
1289
|
if (id === 'graphql') {
|
|
1214
1290
|
const b = (sent?.body ?? {}) as { query?: unknown; variables?: unknown; operationName?: unknown };
|
|
1215
|
-
|
|
1291
|
+
const variables = withoutCredentials(b.variables ?? {}, '', withheld);
|
|
1292
|
+
return { ...activity, graphql: { query: b.query, variables, ...(b.operationName ? { operationName: b.operationName } : {}) }, ...(sent?.lane ? { lane: sent.lane } : {}), headers: headers(sent?.headers ?? {}), ...named() };
|
|
1216
1293
|
}
|
|
1217
1294
|
if (id === 'clock') return { clock: true };
|
|
1218
|
-
if (id === 'unmatched') return { gap: true, ...asSent };
|
|
1219
|
-
if ((m.doors ?? []).some((d) => d.id === id) || (m.screens ?? []).some((sc) => sc.id === id) || (m.accountSetup ?? []).some((a) => a.operation === id) || id === 'machine-pool' || id.startsWith('socket:')) return { door: id, ...asSent };
|
|
1220
|
-
return { operationId: id, ...asSent };
|
|
1295
|
+
if (id === 'unmatched') return { gap: true, ...asSent, ...named() };
|
|
1296
|
+
if ((m.doors ?? []).some((d) => d.id === id) || (m.screens ?? []).some((sc) => sc.id === id) || (m.accountSetup ?? []).some((a) => a.operation === id) || id === 'machine-pool' || id.startsWith('socket:')) return { door: id, ...asSent, ...named() };
|
|
1297
|
+
return { operationId: id, ...asSent, ...named() };
|
|
1221
1298
|
}
|
|
1222
1299
|
|
|
1223
1300
|
/** The scope each call is served in (serveCore, contextFor), for the contexts a write opens for its hooks. */
|
package/src/derived-real.ts
CHANGED
|
@@ -292,6 +292,10 @@ function performOf(units: Unit[]): PerformAction {
|
|
|
292
292
|
const first = group[0]!;
|
|
293
293
|
const unit = unitOf(units, input);
|
|
294
294
|
if (!unit) throw new RefusedWriteError('vendor', `the write records no operation of the vendor's surface (${String(input.operationId ?? 'none')})`, first.id);
|
|
295
|
+
// A credential the request carried was not kept (no World records one in plaintext: ADR 0025), so the request as
|
|
296
|
+
// recorded is not the request that was made; it is refused, never sent without it.
|
|
297
|
+
const withheld = Array.isArray(input.withheld) ? (input.withheld as unknown[]).map(String) : [];
|
|
298
|
+
if (withheld.length) throw new RefusedWriteError('vendor', `the write's ${withheld.join(', ')} ${withheld.length === 1 ? 'was' : 'were'} not kept (no World records a credential in plaintext), so it is not sent again; make it at the vendor`, first.id);
|
|
295
299
|
const { manifest: m, surface } = unit;
|
|
296
300
|
// ids the World minted are sent as the vendor's: every stored type's adopted aliases, tried in turn
|
|
297
301
|
const types = [...new Set(Object.keys(m.resources).map((r) => storedType(m, r)))];
|
package/src/index.ts
CHANGED
|
@@ -72,7 +72,7 @@ export { gunzip, gzip } from './bytes.ts';
|
|
|
72
72
|
export { ownFields } from './log.ts';
|
|
73
73
|
export type { TwinResource } from './serve.ts';
|
|
74
74
|
// the people a vendor's pages sign in (a `_person` row with its password's hash)
|
|
75
|
-
export { passwordHash, personOf, personWith, recordPerson, type Person } from './people.ts';
|
|
75
|
+
export { passwordHash, personOf, personWith, recordPerson, shownPersonFields, type Person } from './people.ts';
|
|
76
76
|
// the World's managed Postgres a handler reaches as `ctx.engine` (managed-database.ts), and a mail's route (`ctx.mail`)
|
|
77
77
|
export { EngineDatabaseError, EngineUnavailableError, quoteIdent } from './managed-database.ts';
|
|
78
78
|
export type { Batch as EngineBatch, ManagedDatabase, Statement as EngineStatement, StatementResult as EngineStatementResult } from './managed-database.ts';
|
package/src/people.ts
CHANGED
|
@@ -24,6 +24,12 @@ export async function recordPerson(ctx: HandlerContext, email: string, password:
|
|
|
24
24
|
export const personOf = (ctx: HandlerContext, email: string): Person | undefined =>
|
|
25
25
|
ctx.rowsRaw(PERSON).find((p) => p.email === email.trim().toLowerCase()) as Person | undefined;
|
|
26
26
|
|
|
27
|
+
/** A person's fields as a reader shows them (the CLI's log and diff, a diff's field changes): the password's hash as
|
|
28
|
+
* `(set)`, never its value. A changeset keeps the hash, which its replay writes (ADR 0025). */
|
|
29
|
+
export function shownPersonFields(fields: Record<string, unknown>): Record<string, unknown> {
|
|
30
|
+
return 'password_sha256' in fields ? { ...fields, password_sha256: '(set)' } : fields;
|
|
31
|
+
}
|
|
32
|
+
|
|
27
33
|
/** The person whose email and password these are, or undefined. */
|
|
28
34
|
export function personWith(ctx: HandlerContext, email: string, password: string): Person | undefined {
|
|
29
35
|
const person = personOf(ctx, email);
|
package/src/runtime.ts
CHANGED
|
@@ -206,12 +206,16 @@ export {
|
|
|
206
206
|
pendingActions,
|
|
207
207
|
pushablePendingActions,
|
|
208
208
|
isTwinBookkeeping,
|
|
209
|
+
isWorldPerson,
|
|
210
|
+
inWorldHistory,
|
|
209
211
|
pendingTransactionCommits,
|
|
210
212
|
projectResources,
|
|
211
213
|
projectOwnerResources,
|
|
212
214
|
OwnerStoreAmbiguousError,
|
|
213
215
|
revertAction,
|
|
214
216
|
TwinActionPreconditionError, subjectAliases, resolveSubjectId, runWithCorrelationId, currentCorrelationId } from './actions.ts';
|
|
217
|
+
// a person as a reader shows it, its password's hash as (set) (ADR 0025)
|
|
218
|
+
export { shownPersonFields } from './people.ts';
|
|
215
219
|
export type {
|
|
216
220
|
ActionProjection,
|
|
217
221
|
ProjectedDelivery,
|