@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.
@@ -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/service-accounts",
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": []
@@ -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, not in a diff, not in a changeset, not pushed. */
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
@@ -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, not in a diff, not in a changeset, not pushed. */
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
@@ -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, isTwinBookkeeping } from "./actions.js";
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 (isTwinBookkeeping(action))
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
- fieldChanges[fieldDiffKey(service, stateService, action.id)] = diffSubjectFields({
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;
@@ -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 asSent = sent ? { ...activity, path: sent.path, query: sent.query, body: safeBody, headers: sent.headers, ...(sent.lane ? { lane: sent.lane } : {}), ...(sent.requestId ? { requestId: sent.requestId } : {}), ...(sent.type ? { contentType: sent.type } : {}) } : { ...activity, path: call.params, body: safeBody };
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
- return { ...activity, graphql: { query: b.query, variables: b.variables ?? {}, ...(b.operationName ? { operationName: b.operationName } : {}) }, ...(sent?.lane ? { lane: sent.lane } : {}), headers: sent?.headers ?? {} };
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();
@@ -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)))];
@@ -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")
@@ -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;
@@ -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);
@@ -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';
@@ -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/service-accounts",
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.158",
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, not in a diff, not in a changeset, not pushed. */
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, isTwinBookkeeping } from './actions.ts';
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 (isTwinBookkeeping(action)) return; // the twin's own store, never a change the app made
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
- fieldChanges[fieldDiffKey(service, stateService, action.id)] = diffSubjectFields({
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;
@@ -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 asSent = sent ? { ...activity, path: sent.path, query: sent.query, body: safeBody, headers: sent.headers, ...(sent.lane ? { lane: sent.lane } : {}), ...(sent.requestId ? { requestId: sent.requestId } : {}), ...(sent.type ? { contentType: sent.type } : {}) } : { ...activity, path: call.params, body: safeBody };
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
- return { ...activity, graphql: { query: b.query, variables: b.variables ?? {}, ...(b.operationName ? { operationName: b.operationName } : {}) }, ...(sent?.lane ? { lane: sent.lane } : {}), headers: sent?.headers ?? {} };
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. */
@@ -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,