@volter/world-core 3.0.162 → 3.0.163
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/src/derived-core.d.ts +35 -2
- package/dist/src/derived-core.js +114 -8
- package/dist/src/packRegistry.d.ts +12 -0
- package/package.json +1 -1
- package/src/derived-core.ts +126 -10
- package/src/packRegistry.ts +7 -1
|
@@ -15,7 +15,7 @@ import { type MultipartPart } from './multipart.js';
|
|
|
15
15
|
import type { CorsDecl } from './cors.js';
|
|
16
16
|
import { type TwinAction } from './actions.js';
|
|
17
17
|
import type { DerivedCall, DerivedCoreOutcome, DerivedHandler, DerivedOperation } from './derived.js';
|
|
18
|
-
import type
|
|
18
|
+
import { type PackDescriptor } from './packRegistry.js';
|
|
19
19
|
import type { ScenarioDecision, ScenarioHandler } from './scenario.js';
|
|
20
20
|
/** How a stored field gets its value when the server assigns it. `now` is the world clock in the
|
|
21
21
|
* manifest's time format; `id` the subject's id; a `value` is stored as given. */
|
|
@@ -921,6 +921,18 @@ export type CoreScope = {
|
|
|
921
921
|
export declare function worldSecret(service: string, root: string, label: string, at?: string): Promise<string>;
|
|
922
922
|
/** The same retained account primitive for pack doors and private native twin fronts. */
|
|
923
923
|
export declare function worldAccount(service: string, root: string | undefined, id?: unknown, at?: string, provisioning?: boolean): Promise<string>;
|
|
924
|
+
type WorldOrganization = {
|
|
925
|
+
id: string;
|
|
926
|
+
label: string;
|
|
927
|
+
members: string[];
|
|
928
|
+
};
|
|
929
|
+
type VendorNameRule = {
|
|
930
|
+
max: number;
|
|
931
|
+
lower?: boolean;
|
|
932
|
+
allowed: RegExp;
|
|
933
|
+
separator?: string;
|
|
934
|
+
first?: RegExp;
|
|
935
|
+
};
|
|
924
936
|
/** The lane a vendor's router sent a request to (pack-fetch's laneRouter sets it). */
|
|
925
937
|
export declare const LANE_HEADER = "x-volter-lane";
|
|
926
938
|
/** What a coverage measurement sees of a pack's declared state logic: each move a request made and each refusal a
|
|
@@ -1018,6 +1030,27 @@ export type SemanticsContext = {
|
|
|
1018
1030
|
secret(label: string): Promise<string>;
|
|
1019
1031
|
/** Select an account at a pack's credential door. Packs retain their own vendor account/key semantics. */
|
|
1020
1032
|
account(field?: string): Promise<string>;
|
|
1033
|
+
/** The selected organization's label and people from the World's organization registry. */
|
|
1034
|
+
organization(): Promise<WorldOrganization>;
|
|
1035
|
+
/** The selected deployment, or an empty string for the organization's own level. */
|
|
1036
|
+
deployment(): string;
|
|
1037
|
+
/** A vendor-safe label; retained links keep identity independent of names. */
|
|
1038
|
+
vendorName(label: string, rule: VendorNameRule, options?: {
|
|
1039
|
+
suffixed?: boolean;
|
|
1040
|
+
identity?: string;
|
|
1041
|
+
}): string;
|
|
1042
|
+
/** Retain the native customer tenant for the selected organization. */
|
|
1043
|
+
tenant(resource: string, make: (organization: WorldOrganization) => Promise<string>): Promise<Record<string, unknown>>;
|
|
1044
|
+
/** Retain one native resource of this kind for the selected organization and deployment. */
|
|
1045
|
+
deploymentObject(kind: string, resource: string, make: (deployment: string) => Promise<string>): Promise<Record<string, unknown>>;
|
|
1046
|
+
/** Reconstruct a live native key, replacing its link when the vendor's reader refuses it. */
|
|
1047
|
+
accountKey(purpose: string, resource: string, key: {
|
|
1048
|
+
make: () => Promise<string>;
|
|
1049
|
+
read: (row: Record<string, unknown>) => Promise<string | undefined>;
|
|
1050
|
+
}): Promise<{
|
|
1051
|
+
id: string;
|
|
1052
|
+
secret: string;
|
|
1053
|
+
}>;
|
|
1021
1054
|
/** The World's machine pool (machines.ts), as the pack's `machine-pool` door enrolled it; `none` when none was. */
|
|
1022
1055
|
machines: MachinePool;
|
|
1023
1056
|
/** A signing key pair for `label` (an issuer's ID tokens, a provider's JWTs, a CA's or a log's key), PEM: RSA 2048
|
|
@@ -1219,7 +1252,7 @@ export type HandlerContext = Omit<SemanticsContext, 'now' | 'core' | 'atomically
|
|
|
1219
1252
|
};
|
|
1220
1253
|
/** What a write hook reads: the writing call's context without its ways to write, so a hook that renders an event
|
|
1221
1254
|
* cannot write again (and run itself again). */
|
|
1222
|
-
export type WriteHookContext = Omit<HandlerContext, 'write' | 'batch' | 'create' | 'createUnique' | 'change' | 'issue' | 'record' | 'accumulate' | 'at' | 'over'>;
|
|
1255
|
+
export type WriteHookContext = Omit<HandlerContext, 'write' | 'batch' | 'create' | 'createUnique' | 'change' | 'issue' | 'record' | 'accumulate' | 'tenant' | 'deploymentObject' | 'accountKey' | 'at' | 'over'>;
|
|
1223
1256
|
/** A derived pack's handler: one operation, by its operationId, over the contract's context. */
|
|
1224
1257
|
export type Handler = (ctx: HandlerContext) => Promise<Response>;
|
|
1225
1258
|
export declare function contextFor(m: DerivedManifest, call: DerivedCall, scope: CoreScope): Promise<SemanticsContext>;
|
package/dist/src/derived-core.js
CHANGED
|
@@ -37,6 +37,7 @@ import { vendorFetch } from "./vendor-call.js";
|
|
|
37
37
|
import { packReferences } from "./references.js";
|
|
38
38
|
import { twinPublicBase, withRequestScopes } from "./twin-fetch.js";
|
|
39
39
|
import { inVendorMove, isReadOnlyRequest, runAsVendorMove, writesRefused } from "./request-scope.js";
|
|
40
|
+
import { getPack } from "./packRegistry.js";
|
|
40
41
|
import { bodyTextOf, checkJsonBody, takeParsedBody } from "./derived.js";
|
|
41
42
|
/** What a move to `to` stores in the field: a boolean field's value is a boolean, and a derived field's
|
|
42
43
|
* value is written by the move's effects (a state name is not a timestamp), so it stores nothing itself. */
|
|
@@ -473,6 +474,57 @@ export async function worldAccount(service, root, id = 'default', at = worldNow(
|
|
|
473
474
|
}, root);
|
|
474
475
|
return id;
|
|
475
476
|
}
|
|
477
|
+
function nativeAccountLink(row, field) {
|
|
478
|
+
if (!row || row.deleted === true)
|
|
479
|
+
return undefined;
|
|
480
|
+
const fields = ownFields(row);
|
|
481
|
+
const value = field === undefined ? fields : fields[field];
|
|
482
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
483
|
+
return undefined;
|
|
484
|
+
const link = value;
|
|
485
|
+
return typeof link.resource === 'string' && typeof link.id === 'string' ? { resource: link.resource, id: link.id } : undefined;
|
|
486
|
+
}
|
|
487
|
+
const sameAccountLink = (a, b) => a?.resource === b?.resource && a?.id === b?.id;
|
|
488
|
+
/** Native creation and reconstruction run outside the action lock; only the retained link is compared and written. */
|
|
489
|
+
async function retainNativeAccountLink(service, root, at, call, type, id, field, resource, make, read) {
|
|
490
|
+
for (;;) {
|
|
491
|
+
const before = nativeAccountLink(resourcesOfType(service, type, root).find((row) => row.id === id), field);
|
|
492
|
+
const held = before && await read(before);
|
|
493
|
+
if (held !== undefined)
|
|
494
|
+
return held;
|
|
495
|
+
const made = { resource, id: await make() };
|
|
496
|
+
if (await read(made) === undefined)
|
|
497
|
+
throw new Error(`Credential ${type}/${id}: native ${resource}/${made.id} is unavailable after creation`);
|
|
498
|
+
const { value } = await applyTwinWriteAtomic(service, (snapshot) => {
|
|
499
|
+
const current = nativeAccountLink(snapshot.find((row) => row.type === type && row.id === id), field);
|
|
500
|
+
if (!sameAccountLink(current, before))
|
|
501
|
+
return { kind: 'skip', value: current };
|
|
502
|
+
return {
|
|
503
|
+
kind: 'write', value: made,
|
|
504
|
+
write: {
|
|
505
|
+
operation: `${type.slice(1)}.link`, subjectType: type, subjectId: id,
|
|
506
|
+
fields: field === undefined ? made : { name: id, [field]: made },
|
|
507
|
+
...provisioningInput(call), occurredAt: at, actor: { kind: 'system' },
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
}, root);
|
|
511
|
+
const linked = value && await read(value);
|
|
512
|
+
if (linked !== undefined)
|
|
513
|
+
return linked;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
function vendorName(label, rule, { suffixed = false, identity = label } = {}) {
|
|
517
|
+
const allowed = new RegExp(rule.allowed.source, rule.allowed.flags.replace(/[gy]/g, ''));
|
|
518
|
+
const separator = rule.separator ?? '-';
|
|
519
|
+
if ([...separator].length !== 1 || !allowed.test(separator))
|
|
520
|
+
throw new Error('Credential name separator must be one allowed character');
|
|
521
|
+
const first = rule.first && new RegExp(rule.first.source, rule.first.flags.replace(/[gy]/g, ''));
|
|
522
|
+
let name = [...(rule.lower ? label.toLowerCase() : label)].map((letter) => allowed.test(letter) ? letter : separator).join('');
|
|
523
|
+
name = name.replace(new RegExp(`${escapeRe(separator)}+`, 'g'), separator);
|
|
524
|
+
if (first && !first.test(name[0] ?? ''))
|
|
525
|
+
name = `w${separator}${name}`;
|
|
526
|
+
return name !== label || name.length > rule.max || suffixed ? `${name.slice(0, Math.max(0, rule.max - 9))}${separator}${sha256(identity).slice(0, 8)}` : name;
|
|
527
|
+
}
|
|
476
528
|
/** A World's signing key for a label (ctx.signingKey): made once from the platform's random source, then read; the
|
|
477
529
|
* making is one per service store and label in the process, as the seed's is. */
|
|
478
530
|
const SIGNING_KEY = '_world_signing_key';
|
|
@@ -1489,6 +1541,8 @@ export async function contextFor(m, call, scope) {
|
|
|
1489
1541
|
});
|
|
1490
1542
|
const paths = expandPaths(m, params);
|
|
1491
1543
|
const stateChecks = new Map();
|
|
1544
|
+
const credentialDoor = m.descriptor?.credentialDoor ?? getPack(m.vendor)?.credentialDoor;
|
|
1545
|
+
const credentialFields = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
|
|
1492
1546
|
const rawResources = new WeakMap();
|
|
1493
1547
|
const rawRow = (resource, row) => {
|
|
1494
1548
|
const name = m.resources[resource] ? resource : Object.keys(m.resources).find((name) => storedType(m, name) === row.type);
|
|
@@ -1502,6 +1556,10 @@ export async function contextFor(m, call, scope) {
|
|
|
1502
1556
|
rawResources.set(row, name ?? resource);
|
|
1503
1557
|
return row;
|
|
1504
1558
|
};
|
|
1559
|
+
const liveNativeRow = (link) => {
|
|
1560
|
+
const row = context.row(link.resource, link.id);
|
|
1561
|
+
return row && row.deleted !== true ? row : undefined;
|
|
1562
|
+
};
|
|
1505
1563
|
const context = {
|
|
1506
1564
|
call,
|
|
1507
1565
|
params,
|
|
@@ -1558,9 +1616,38 @@ export async function contextFor(m, call, scope) {
|
|
|
1558
1616
|
},
|
|
1559
1617
|
occurredAt: at,
|
|
1560
1618
|
secret: async (label) => hmac(await worldSeed(m.service, root, at), scopeLabel(m, label), 'base64url'),
|
|
1561
|
-
account: async (field = 'account') => {
|
|
1562
|
-
|
|
1563
|
-
|
|
1619
|
+
account: async (field = credentialDoor?.account?.field ?? 'account') => {
|
|
1620
|
+
return worldAccount(m.service, root, credentialFields[field] ?? 'default', at, call.request.headers.get('x-volter-world-activity') === 'provisioning');
|
|
1621
|
+
},
|
|
1622
|
+
organization: async () => {
|
|
1623
|
+
const id = await context.account();
|
|
1624
|
+
const row = projectOwnerResources('_world', root, { type: '_world_organization', id }).find((row) => row.type === '_world_organization' && row.id === id && row.deleted !== true);
|
|
1625
|
+
return row ? { id, label: row.label, members: [...row.members] } : { id, label: id, members: ['owner@world.test'] };
|
|
1626
|
+
},
|
|
1627
|
+
deployment: () => {
|
|
1628
|
+
const id = credentialFields[credentialDoor?.deployment?.field ?? 'deployment'] ?? '';
|
|
1629
|
+
if (id !== '' && !isWorldAccountName(id))
|
|
1630
|
+
throw new Error('Credential deployment must be a name of at most 128 letters, digits, dots, underscores or hyphens');
|
|
1631
|
+
return id;
|
|
1632
|
+
},
|
|
1633
|
+
vendorName,
|
|
1634
|
+
tenant: async (resource, make) => {
|
|
1635
|
+
const id = await context.account();
|
|
1636
|
+
return retainNativeAccountLink(m.service, root, at, call, '_world_account', id, 'tenant', resource, async () => make(await context.organization()), async (link) => liveNativeRow(link));
|
|
1637
|
+
},
|
|
1638
|
+
deploymentObject: async (kind, resource, make) => {
|
|
1639
|
+
const organization = await context.account(), deployment = context.deployment();
|
|
1640
|
+
return retainNativeAccountLink(m.service, root, at, call, '_world_deployment_object', `${organization}/${deployment}/${kind}`, undefined, resource, () => make(deployment), async (link) => liveNativeRow(link));
|
|
1641
|
+
},
|
|
1642
|
+
accountKey: async (purpose, resource, key) => {
|
|
1643
|
+
const organization = await context.account(), deployment = context.deployment();
|
|
1644
|
+
return retainNativeAccountLink(m.service, root, at, call, '_world_account_key', `${organization}/${deployment}/${purpose}`, undefined, resource, () => key.make(), async (link) => {
|
|
1645
|
+
const row = liveNativeRow(link);
|
|
1646
|
+
if (!row)
|
|
1647
|
+
return undefined;
|
|
1648
|
+
const secret = await key.read(row);
|
|
1649
|
+
return secret === undefined ? undefined : { id: link.id, secret };
|
|
1650
|
+
});
|
|
1564
1651
|
},
|
|
1565
1652
|
record: async (type, fields, id, operation) => {
|
|
1566
1653
|
if (!type.startsWith('_'))
|
|
@@ -1621,7 +1708,12 @@ export async function contextFor(m, call, scope) {
|
|
|
1621
1708
|
const row = await atomicWrite(m, call, resource, (snapshot) => {
|
|
1622
1709
|
const id = options?.id ?? mintId(m, resource, root, at, snapshot);
|
|
1623
1710
|
const selected = typeof fields === 'function' ? fields(id) : fields;
|
|
1624
|
-
const hit = snapshot.find((r) =>
|
|
1711
|
+
const hit = snapshot.find((r) => {
|
|
1712
|
+
if (r.type !== storedType(m, resource) || r.deleted === true)
|
|
1713
|
+
return false;
|
|
1714
|
+
const fields = { [m.resources[resource]?.idAs ?? 'id']: r.id, ...ownFields(r) };
|
|
1715
|
+
return rules.some((rule) => rule.fields.every((field) => selected[field] !== undefined && fields[field] === selected[field]));
|
|
1716
|
+
});
|
|
1625
1717
|
if (hit) {
|
|
1626
1718
|
existing = view(m, resource, hit);
|
|
1627
1719
|
return undefined;
|
|
@@ -1661,7 +1753,7 @@ export async function contextFor(m, call, scope) {
|
|
|
1661
1753
|
},
|
|
1662
1754
|
writeDetailed: (resource, id, fields, operation) => writeDetailed(m, call, resource, id, fields, operation, params, root, at, stateChecks),
|
|
1663
1755
|
resolve: (resource, id) => resolveSubjectId(m.service, storedType(m, resource), id, root),
|
|
1664
|
-
storedType: (resource) =>
|
|
1756
|
+
storedType: (resource) => m.resources[resource]?.storedAs ?? resource,
|
|
1665
1757
|
ok: (fields) => Response.json({ ...(m.success ?? {}), ...fields }),
|
|
1666
1758
|
row: (resource, id, opts) => { const row = stored(m, resource, root, opts).find((r) => r.id === id); return row ? rawRow(resource, row) : undefined; },
|
|
1667
1759
|
rowsRaw: (resource, opts) => stored(m, resource, root, opts).map((row) => rawRow(resource, row)),
|
|
@@ -1782,10 +1874,12 @@ const BATCH_REFUSED_READS = ['get', 'rows', 'tree', 'history', 'find', 'list', '
|
|
|
1782
1874
|
/** The members that write, or wait on writes, or answer as another context: each lands the staged writes first. */
|
|
1783
1875
|
const BATCH_LANDING_MEMBERS = ['create', 'createUnique', 'change', 'accumulate', 'writeDetailed', 'record', 'atomically', 'remove', 'issue', 'at', 'over', 'core', 'awaitWrite', 'awaitCondition',
|
|
1784
1876
|
// a World's seed and signing keys are written when first asked (worldSeed, worldSigningKey, as a vendor's move)
|
|
1785
|
-
'secret', 'signingKey'];
|
|
1877
|
+
'secret', 'signingKey', 'account', 'organization', 'tenant', 'deploymentObject', 'accountKey'];
|
|
1786
1878
|
/** `ctx.batch` over a context whose writes may be grouped (see `batchable`). */
|
|
1787
1879
|
function bindBatch(m, call, context, params, root, at, rawRow) {
|
|
1788
1880
|
let staged;
|
|
1881
|
+
// A callback's native object must land before the helper writes its retained link.
|
|
1882
|
+
let nativeAccountWrites = 0;
|
|
1789
1883
|
// each staged subject as it will stand, by `type:id`, in the order first written
|
|
1790
1884
|
const subjects = new Map();
|
|
1791
1885
|
const land = async () => {
|
|
@@ -1801,7 +1895,19 @@ function bindBatch(m, call, context, params, root, at, rawRow) {
|
|
|
1801
1895
|
const plain = ctx[name];
|
|
1802
1896
|
if (typeof plain !== 'function')
|
|
1803
1897
|
continue;
|
|
1804
|
-
ctx[name] = async (...args) => {
|
|
1898
|
+
ctx[name] = async (...args) => {
|
|
1899
|
+
await land();
|
|
1900
|
+
const native = name === 'tenant' || name === 'deploymentObject' || name === 'accountKey';
|
|
1901
|
+
if (native)
|
|
1902
|
+
nativeAccountWrites++;
|
|
1903
|
+
try {
|
|
1904
|
+
return await plain(...args);
|
|
1905
|
+
}
|
|
1906
|
+
finally {
|
|
1907
|
+
if (native)
|
|
1908
|
+
nativeAccountWrites--;
|
|
1909
|
+
}
|
|
1910
|
+
};
|
|
1805
1911
|
}
|
|
1806
1912
|
for (const name of BATCH_REFUSED_READS) {
|
|
1807
1913
|
const plain = ctx[name];
|
|
@@ -1816,7 +1922,7 @@ function bindBatch(m, call, context, params, root, at, rawRow) {
|
|
|
1816
1922
|
const plainWrite = context.write, plainRow = context.row, plainRowsRaw = context.rowsRaw;
|
|
1817
1923
|
context.write = async (resource, id, fields, operation) => {
|
|
1818
1924
|
// a vendor's own move (ctx.asVendor) is the clock's write, recorded as the system's: it lands alone, in its place
|
|
1819
|
-
if (!staged || inVendorMove()) {
|
|
1925
|
+
if (!staged || nativeAccountWrites > 0 || inVendorMove()) {
|
|
1820
1926
|
await land();
|
|
1821
1927
|
return plainWrite(resource, id, fields, operation);
|
|
1822
1928
|
}
|
|
@@ -204,6 +204,18 @@ export type TwinPack = {
|
|
|
204
204
|
fill: Record<string, string>;
|
|
205
205
|
account?: {
|
|
206
206
|
field: string;
|
|
207
|
+
} & ({
|
|
208
|
+
tenancy?: never;
|
|
209
|
+
source?: never;
|
|
210
|
+
} | {
|
|
211
|
+
tenancy: string;
|
|
212
|
+
source: string;
|
|
213
|
+
});
|
|
214
|
+
deployment?: {
|
|
215
|
+
field: string;
|
|
216
|
+
object: string;
|
|
217
|
+
source: string;
|
|
218
|
+
organizationOutputs?: string[];
|
|
207
219
|
};
|
|
208
220
|
};
|
|
209
221
|
/** Another pack of this vendor issues the API's credentials; architecture, descriptor World wiring. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-core",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.163",
|
|
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/derived-core.ts
CHANGED
|
@@ -43,7 +43,7 @@ import type { SubjectFields } from './hash.ts';
|
|
|
43
43
|
import { twinPublicBase, withRequestScopes } from './twin-fetch.ts';
|
|
44
44
|
import { inVendorMove, isReadOnlyRequest, runAsVendorMove, writesRefused } from './request-scope.ts';
|
|
45
45
|
import type { DerivedCall, DerivedCoreOutcome, DerivedHandler, DerivedOperation } from './derived.ts';
|
|
46
|
-
import type
|
|
46
|
+
import { getPack, type PackDescriptor } from './packRegistry.ts';
|
|
47
47
|
import type { ScenarioDecision, ScenarioHandler } from './scenario.ts';
|
|
48
48
|
import { bodyTextOf, checkJsonBody, takeParsedBody } from './derived.ts';
|
|
49
49
|
|
|
@@ -996,6 +996,62 @@ export async function worldAccount(service: string, root: string | undefined, id
|
|
|
996
996
|
return id;
|
|
997
997
|
}
|
|
998
998
|
|
|
999
|
+
type WorldOrganization = { id: string; label: string; members: string[] };
|
|
1000
|
+
type VendorNameRule = { max: number; lower?: boolean; allowed: RegExp; separator?: string; first?: RegExp };
|
|
1001
|
+
type NativeAccountLink = { resource: string; id: string };
|
|
1002
|
+
|
|
1003
|
+
function nativeAccountLink(row: TwinResource | undefined, field?: string): NativeAccountLink | undefined {
|
|
1004
|
+
if (!row || row.deleted === true) return undefined;
|
|
1005
|
+
const fields = ownFields(row);
|
|
1006
|
+
const value = field === undefined ? fields : fields[field];
|
|
1007
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
1008
|
+
const link = value as Record<string, unknown>;
|
|
1009
|
+
return typeof link.resource === 'string' && typeof link.id === 'string' ? { resource: link.resource, id: link.id } : undefined;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const sameAccountLink = (a: NativeAccountLink | undefined, b: NativeAccountLink | undefined): boolean =>
|
|
1013
|
+
a?.resource === b?.resource && a?.id === b?.id;
|
|
1014
|
+
|
|
1015
|
+
/** Native creation and reconstruction run outside the action lock; only the retained link is compared and written. */
|
|
1016
|
+
async function retainNativeAccountLink<T>(
|
|
1017
|
+
service: string, root: string | undefined, at: string, call: DerivedCall,
|
|
1018
|
+
type: string, id: string, field: string | undefined, resource: string,
|
|
1019
|
+
make: () => Promise<string>, read: (link: NativeAccountLink) => Promise<T | undefined>,
|
|
1020
|
+
): Promise<T> {
|
|
1021
|
+
for (;;) {
|
|
1022
|
+
const before = nativeAccountLink(resourcesOfType(service, type, root).find((row) => row.id === id), field);
|
|
1023
|
+
const held = before && await read(before);
|
|
1024
|
+
if (held !== undefined) return held;
|
|
1025
|
+
const made = { resource, id: await make() };
|
|
1026
|
+
if (await read(made) === undefined) throw new Error(`Credential ${type}/${id}: native ${resource}/${made.id} is unavailable after creation`);
|
|
1027
|
+
const { value } = await applyTwinWriteAtomic(service, (snapshot) => {
|
|
1028
|
+
const current = nativeAccountLink(snapshot.find((row) => row.type === type && row.id === id), field);
|
|
1029
|
+
if (!sameAccountLink(current, before)) return { kind: 'skip', value: current };
|
|
1030
|
+
return {
|
|
1031
|
+
kind: 'write', value: made,
|
|
1032
|
+
write: {
|
|
1033
|
+
operation: `${type.slice(1)}.link`, subjectType: type, subjectId: id,
|
|
1034
|
+
fields: field === undefined ? made : { name: id, [field]: made },
|
|
1035
|
+
...provisioningInput(call), occurredAt: at, actor: { kind: 'system' },
|
|
1036
|
+
},
|
|
1037
|
+
};
|
|
1038
|
+
}, root);
|
|
1039
|
+
const linked = value && await read(value);
|
|
1040
|
+
if (linked !== undefined) return linked;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function vendorName(label: string, rule: VendorNameRule, { suffixed = false, identity = label }: { suffixed?: boolean; identity?: string } = {}): string {
|
|
1045
|
+
const allowed = new RegExp(rule.allowed.source, rule.allowed.flags.replace(/[gy]/g, ''));
|
|
1046
|
+
const separator = rule.separator ?? '-';
|
|
1047
|
+
if ([...separator].length !== 1 || !allowed.test(separator)) throw new Error('Credential name separator must be one allowed character');
|
|
1048
|
+
const first = rule.first && new RegExp(rule.first.source, rule.first.flags.replace(/[gy]/g, ''));
|
|
1049
|
+
let name = [...(rule.lower ? label.toLowerCase() : label)].map((letter) => allowed.test(letter) ? letter : separator).join('');
|
|
1050
|
+
name = name.replace(new RegExp(`${escapeRe(separator)}+`, 'g'), separator);
|
|
1051
|
+
if (first && !first.test(name[0] ?? '')) name = `w${separator}${name}`;
|
|
1052
|
+
return name !== label || name.length > rule.max || suffixed ? `${name.slice(0, Math.max(0, rule.max - 9))}${separator}${sha256(identity).slice(0, 8)}` : name;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
999
1055
|
/** A World's signing key for a label (ctx.signingKey): made once from the platform's random source, then read; the
|
|
1000
1056
|
* making is one per service store and label in the process, as the seed's is. */
|
|
1001
1057
|
const SIGNING_KEY = '_world_signing_key';
|
|
@@ -1959,6 +2015,18 @@ export type SemanticsContext = {
|
|
|
1959
2015
|
secret(label: string): Promise<string>;
|
|
1960
2016
|
/** Select an account at a pack's credential door. Packs retain their own vendor account/key semantics. */
|
|
1961
2017
|
account(field?: string): Promise<string>;
|
|
2018
|
+
/** The selected organization's label and people from the World's organization registry. */
|
|
2019
|
+
organization(): Promise<WorldOrganization>;
|
|
2020
|
+
/** The selected deployment, or an empty string for the organization's own level. */
|
|
2021
|
+
deployment(): string;
|
|
2022
|
+
/** A vendor-safe label; retained links keep identity independent of names. */
|
|
2023
|
+
vendorName(label: string, rule: VendorNameRule, options?: { suffixed?: boolean; identity?: string }): string;
|
|
2024
|
+
/** Retain the native customer tenant for the selected organization. */
|
|
2025
|
+
tenant(resource: string, make: (organization: WorldOrganization) => Promise<string>): Promise<Record<string, unknown>>;
|
|
2026
|
+
/** Retain one native resource of this kind for the selected organization and deployment. */
|
|
2027
|
+
deploymentObject(kind: string, resource: string, make: (deployment: string) => Promise<string>): Promise<Record<string, unknown>>;
|
|
2028
|
+
/** Reconstruct a live native key, replacing its link when the vendor's reader refuses it. */
|
|
2029
|
+
accountKey(purpose: string, resource: string, key: { make: () => Promise<string>; read: (row: Record<string, unknown>) => Promise<string | undefined> }): Promise<{ id: string; secret: string }>;
|
|
1962
2030
|
/** The World's machine pool (machines.ts), as the pack's `machine-pool` door enrolled it; `none` when none was. */
|
|
1963
2031
|
machines: MachinePool;
|
|
1964
2032
|
/** A signing key pair for `label` (an issuer's ID tokens, a provider's JWTs, a CA's or a log's key), PEM: RSA 2048
|
|
@@ -2105,7 +2173,7 @@ export type HandlerContext = Omit<SemanticsContext, 'now' | 'core' | 'atomically
|
|
|
2105
2173
|
};
|
|
2106
2174
|
/** What a write hook reads: the writing call's context without its ways to write, so a hook that renders an event
|
|
2107
2175
|
* cannot write again (and run itself again). */
|
|
2108
|
-
export type WriteHookContext = Omit<HandlerContext, 'write' | 'batch' | 'create' | 'createUnique' | 'change' | 'issue' | 'record' | 'accumulate' | 'at' | 'over'>;
|
|
2176
|
+
export type WriteHookContext = Omit<HandlerContext, 'write' | 'batch' | 'create' | 'createUnique' | 'change' | 'issue' | 'record' | 'accumulate' | 'tenant' | 'deploymentObject' | 'accountKey' | 'at' | 'over'>;
|
|
2109
2177
|
/** A derived pack's handler: one operation, by its operationId, over the contract's context. */
|
|
2110
2178
|
export type Handler = (ctx: HandlerContext) => Promise<Response>;
|
|
2111
2179
|
|
|
@@ -2153,6 +2221,8 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2153
2221
|
});
|
|
2154
2222
|
const paths = expandPaths(m, params);
|
|
2155
2223
|
const stateChecks: StateChecks = new Map();
|
|
2224
|
+
const credentialDoor = m.descriptor?.credentialDoor ?? getPack(m.vendor)?.credentialDoor;
|
|
2225
|
+
const credentialFields = body && typeof body === 'object' && !Array.isArray(body) ? body as Record<string, unknown> : {};
|
|
2156
2226
|
const rawResources = new WeakMap<Record<string, unknown>, string>();
|
|
2157
2227
|
const rawRow = (resource: string, row: TwinResource): Record<string, unknown> => {
|
|
2158
2228
|
const name = m.resources[resource] ? resource : Object.keys(m.resources).find((name) => storedType(m, name) === row.type);
|
|
@@ -2166,6 +2236,10 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2166
2236
|
rawResources.set(row as Record<string, unknown>, name ?? resource);
|
|
2167
2237
|
return row as Record<string, unknown>;
|
|
2168
2238
|
};
|
|
2239
|
+
const liveNativeRow = (link: NativeAccountLink): Record<string, unknown> | undefined => {
|
|
2240
|
+
const row = context.row(link.resource, link.id);
|
|
2241
|
+
return row && row.deleted !== true ? row : undefined;
|
|
2242
|
+
};
|
|
2169
2243
|
const context: SemanticsContext = {
|
|
2170
2244
|
call,
|
|
2171
2245
|
params,
|
|
@@ -2218,9 +2292,39 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2218
2292
|
},
|
|
2219
2293
|
occurredAt: at,
|
|
2220
2294
|
secret: async (label) => hmac(await worldSeed(m.service, root, at), scopeLabel(m, label), 'base64url'),
|
|
2221
|
-
account: async (field = 'account') => {
|
|
2222
|
-
|
|
2223
|
-
|
|
2295
|
+
account: async (field = credentialDoor?.account?.field ?? 'account') => {
|
|
2296
|
+
return worldAccount(m.service, root, credentialFields[field] ?? 'default', at, call.request.headers.get('x-volter-world-activity') === 'provisioning');
|
|
2297
|
+
},
|
|
2298
|
+
organization: async () => {
|
|
2299
|
+
const id = await context.account();
|
|
2300
|
+
const row = projectOwnerResources('_world', root, { type: '_world_organization', id }).find((row) => row.type === '_world_organization' && row.id === id && row.deleted !== true);
|
|
2301
|
+
return row ? { id, label: row.label as string, members: [...(row.members as string[])] } : { id, label: id, members: ['owner@world.test'] };
|
|
2302
|
+
},
|
|
2303
|
+
deployment: () => {
|
|
2304
|
+
const id = credentialFields[credentialDoor?.deployment?.field ?? 'deployment'] ?? '';
|
|
2305
|
+
if (id !== '' && !isWorldAccountName(id)) throw new Error('Credential deployment must be a name of at most 128 letters, digits, dots, underscores or hyphens');
|
|
2306
|
+
return id;
|
|
2307
|
+
},
|
|
2308
|
+
vendorName,
|
|
2309
|
+
tenant: async (resource, make) => {
|
|
2310
|
+
const id = await context.account();
|
|
2311
|
+
return retainNativeAccountLink(m.service, root, at, call, '_world_account', id, 'tenant', resource,
|
|
2312
|
+
async () => make(await context.organization()), async (link) => liveNativeRow(link));
|
|
2313
|
+
},
|
|
2314
|
+
deploymentObject: async (kind, resource, make) => {
|
|
2315
|
+
const organization = await context.account(), deployment = context.deployment();
|
|
2316
|
+
return retainNativeAccountLink(m.service, root, at, call, '_world_deployment_object', `${organization}/${deployment}/${kind}`, undefined, resource,
|
|
2317
|
+
() => make(deployment), async (link) => liveNativeRow(link));
|
|
2318
|
+
},
|
|
2319
|
+
accountKey: async (purpose, resource, key) => {
|
|
2320
|
+
const organization = await context.account(), deployment = context.deployment();
|
|
2321
|
+
return retainNativeAccountLink(m.service, root, at, call, '_world_account_key', `${organization}/${deployment}/${purpose}`, undefined, resource,
|
|
2322
|
+
() => key.make(), async (link) => {
|
|
2323
|
+
const row = liveNativeRow(link);
|
|
2324
|
+
if (!row) return undefined;
|
|
2325
|
+
const secret = await key.read(row);
|
|
2326
|
+
return secret === undefined ? undefined : { id: link.id, secret };
|
|
2327
|
+
});
|
|
2224
2328
|
},
|
|
2225
2329
|
record: async (type, fields, id, operation) => {
|
|
2226
2330
|
if (!type.startsWith('_')) throw new Error(`semantics: ${type} is not a bookkeeping type (bookkeeping types start with _)`);
|
|
@@ -2272,7 +2376,11 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2272
2376
|
const row = await atomicWrite(m, call, resource, (snapshot) => {
|
|
2273
2377
|
const id = options?.id ?? mintId(m, resource, root, at, snapshot);
|
|
2274
2378
|
const selected = typeof fields === 'function' ? fields(id) : fields;
|
|
2275
|
-
const hit = snapshot.find((r) =>
|
|
2379
|
+
const hit = snapshot.find((r) => {
|
|
2380
|
+
if (r.type !== storedType(m, resource) || r.deleted === true) return false;
|
|
2381
|
+
const fields = { [m.resources[resource]?.idAs ?? 'id']: r.id, ...ownFields(r) };
|
|
2382
|
+
return rules.some((rule) => rule.fields.every((field) => selected[field] !== undefined && fields[field] === selected[field]));
|
|
2383
|
+
});
|
|
2276
2384
|
if (hit) { existing = view(m, resource, hit); return undefined; }
|
|
2277
2385
|
return { id, fields: selected };
|
|
2278
2386
|
}, operation, params, root, at, undefined, stateChecks, options?.input === 'omit');
|
|
@@ -2304,7 +2412,7 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
|
|
|
2304
2412
|
},
|
|
2305
2413
|
writeDetailed: (resource, id, fields, operation) => writeDetailed(m, call, resource, id, fields, operation, params, root, at, stateChecks),
|
|
2306
2414
|
resolve: (resource, id) => resolveSubjectId(m.service, storedType(m, resource), id, root),
|
|
2307
|
-
storedType: (resource) =>
|
|
2415
|
+
storedType: (resource) => m.resources[resource]?.storedAs ?? resource,
|
|
2308
2416
|
ok: (fields) => Response.json({ ...(m.success ?? {}), ...fields }),
|
|
2309
2417
|
row: (resource, id, opts) => { const row = stored(m, resource, root, opts).find((r) => r.id === id); return row ? rawRow(resource, row) : undefined; },
|
|
2310
2418
|
rowsRaw: (resource, opts) => stored(m, resource, root, opts).map((row) => rawRow(resource, row)),
|
|
@@ -2427,12 +2535,14 @@ const BATCH_REFUSED_READS = ['get', 'rows', 'tree', 'history', 'find', 'list', '
|
|
|
2427
2535
|
/** The members that write, or wait on writes, or answer as another context: each lands the staged writes first. */
|
|
2428
2536
|
const BATCH_LANDING_MEMBERS = ['create', 'createUnique', 'change', 'accumulate', 'writeDetailed', 'record', 'atomically', 'remove', 'issue', 'at', 'over', 'core', 'awaitWrite', 'awaitCondition',
|
|
2429
2537
|
// a World's seed and signing keys are written when first asked (worldSeed, worldSigningKey, as a vendor's move)
|
|
2430
|
-
'secret', 'signingKey'] as const;
|
|
2538
|
+
'secret', 'signingKey', 'account', 'organization', 'tenant', 'deploymentObject', 'accountKey'] as const;
|
|
2431
2539
|
|
|
2432
2540
|
/** `ctx.batch` over a context whose writes may be grouped (see `batchable`). */
|
|
2433
2541
|
function bindBatch(m: DerivedManifest, call: DerivedCall, context: SemanticsContext, params: Record<string, unknown>, root: string | undefined, at: string,
|
|
2434
2542
|
rawRow: (resource: string, row: TwinResource) => Record<string, unknown>): void {
|
|
2435
2543
|
let staged: StagedWrite[] | undefined;
|
|
2544
|
+
// A callback's native object must land before the helper writes its retained link.
|
|
2545
|
+
let nativeAccountWrites = 0;
|
|
2436
2546
|
// each staged subject as it will stand, by `type:id`, in the order first written
|
|
2437
2547
|
const subjects = new Map<string, Subject>();
|
|
2438
2548
|
const land = async (): Promise<void> => {
|
|
@@ -2446,7 +2556,13 @@ function bindBatch(m: DerivedManifest, call: DerivedCall, context: SemanticsCont
|
|
|
2446
2556
|
for (const name of BATCH_LANDING_MEMBERS) {
|
|
2447
2557
|
const plain = ctx[name];
|
|
2448
2558
|
if (typeof plain !== 'function') continue;
|
|
2449
|
-
ctx[name] = async (...args: unknown[]) => {
|
|
2559
|
+
ctx[name] = async (...args: unknown[]) => {
|
|
2560
|
+
await land();
|
|
2561
|
+
const native = name === 'tenant' || name === 'deploymentObject' || name === 'accountKey';
|
|
2562
|
+
if (native) nativeAccountWrites++;
|
|
2563
|
+
try { return await (plain as (...a: unknown[]) => unknown)(...args); }
|
|
2564
|
+
finally { if (native) nativeAccountWrites--; }
|
|
2565
|
+
};
|
|
2450
2566
|
}
|
|
2451
2567
|
for (const name of BATCH_REFUSED_READS) {
|
|
2452
2568
|
const plain = ctx[name];
|
|
@@ -2459,7 +2575,7 @@ function bindBatch(m: DerivedManifest, call: DerivedCall, context: SemanticsCont
|
|
|
2459
2575
|
const plainWrite = context.write, plainRow = context.row, plainRowsRaw = context.rowsRaw;
|
|
2460
2576
|
context.write = async (resource, id, fields, operation) => {
|
|
2461
2577
|
// a vendor's own move (ctx.asVendor) is the clock's write, recorded as the system's: it lands alone, in its place
|
|
2462
|
-
if (!staged || inVendorMove()) { await land(); return plainWrite(resource, id, fields, operation); }
|
|
2578
|
+
if (!staged || nativeAccountWrites > 0 || inVendorMove()) { await land(); return plainWrite(resource, id, fields, operation); }
|
|
2463
2579
|
const type = storedType(m, resource), key = `${type}:${id}`;
|
|
2464
2580
|
// the one subject, as the memoized tree holds it (readTreeMap would copy the whole tree on every staged write)
|
|
2465
2581
|
const subject = foldLocalWrite(subjects.get(key) ?? heldSubject(m.service, type, id, root), type, id, fields as SubjectFields, at);
|
package/src/packRegistry.ts
CHANGED
|
@@ -221,7 +221,13 @@ export type TwinPack = {
|
|
|
221
221
|
* answer: `init` writes `$issue:<service>` for each, and `up`, once this twin is running, POSTs the body to the door
|
|
222
222
|
* and sets each name to its field (`file:<field>` materializes one file; `directory:<field>` a relative-file map). A twin that holds its keys (refusing any it
|
|
223
223
|
* did not issue) declares one, so a World's applications hold keys it issued rather than fixtures it refuses. */
|
|
224
|
-
credentialDoor?: {
|
|
224
|
+
credentialDoor?: {
|
|
225
|
+
path: string;
|
|
226
|
+
body: Record<string, unknown>;
|
|
227
|
+
fill: Record<string, string>;
|
|
228
|
+
account?: { field: string } & ({ tenancy?: never; source?: never } | { tenancy: string; source: string });
|
|
229
|
+
deployment?: { field: string; object: string; source: string; organizationOutputs?: string[] };
|
|
230
|
+
};
|
|
225
231
|
/** Another pack of this vendor issues the API's credentials; architecture, descriptor World wiring. */
|
|
226
232
|
credentialIssuer?: string;
|
|
227
233
|
prismaAdapter?: { adapter: string; export: string; urlEnv: string };
|