@tinycloud/node-sdk 2.11.0-beta.1 → 2.11.0-beta.11

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/core.cjs CHANGED
@@ -322,6 +322,8 @@ var FileSessionStorage = class {
322
322
  };
323
323
 
324
324
  // src/authorization/NodeUserAuthorization.ts
325
+ var import_node_crypto = require("crypto");
326
+ var import_siwe = require("siwe");
325
327
  var import_sdk_core3 = require("@tinycloud/sdk-core");
326
328
 
327
329
  // src/authorization/strategies.ts
@@ -331,6 +333,27 @@ var defaultSignStrategy = { type: "auto-sign" };
331
333
  // src/authorization/NodeUserAuthorization.ts
332
334
  var DECRYPT_ACTION = import_sdk_core3.ENCRYPTION.DECRYPT;
333
335
  var NETWORK_CREATE_ACTION = import_sdk_core3.ENCRYPTION.NETWORK_CREATE;
336
+ function canonicalizeOpenKeyManifestJson(value) {
337
+ const json = JSON.stringify(value);
338
+ if (json === void 0) {
339
+ throw new TypeError("manifest is not JSON-serializable");
340
+ }
341
+ return canonicalizeJsonValue(JSON.parse(json));
342
+ }
343
+ function canonicalizeJsonValue(value) {
344
+ if (value === null || typeof value !== "object") {
345
+ return JSON.stringify(value);
346
+ }
347
+ if (Array.isArray(value)) {
348
+ return `[${value.map(canonicalizeJsonValue).join(",")}]`;
349
+ }
350
+ return `{${Object.keys(value).sort().map(
351
+ (key) => `${JSON.stringify(key)}:${canonicalizeJsonValue(value[key])}`
352
+ ).join(",")}}`;
353
+ }
354
+ function canonicalOpenKeyManifestSha256Hex(value) {
355
+ return (0, import_node_crypto.createHash)("sha256").update(canonicalizeOpenKeyManifestJson(value)).digest("hex");
356
+ }
334
357
  function didPrincipalMatches(actual, expected) {
335
358
  try {
336
359
  return (0, import_sdk_core3.principalDidEquals)(actual, expected);
@@ -348,6 +371,16 @@ function addRawAbility(rawAbilities, resource, action) {
348
371
  actions.push(action);
349
372
  }
350
373
  }
374
+ function cloneAbilitiesMap(actions) {
375
+ return Object.fromEntries(
376
+ Object.entries(actions).map(([service, paths]) => [
377
+ service,
378
+ Object.fromEntries(
379
+ Object.entries(paths).map(([path, pathActions]) => [path, [...pathActions]])
380
+ )
381
+ ])
382
+ );
383
+ }
351
384
  var NodeUserAuthorization = class {
352
385
  constructor(config) {
353
386
  this.activeSessionKeyId = "default";
@@ -551,6 +584,15 @@ var NodeUserAuthorization = class {
551
584
  get nodeFeatures() {
552
585
  return this._nodeFeatures;
553
586
  }
587
+ /**
588
+ * The node DID reported by `GET /info` for `host`, if the sign-in (or
589
+ * restore) that populated it targeted that same host. Returns `undefined`
590
+ * for any other host, and for nodes whose `/info` omits `nodeId`, so callers
591
+ * fall back to fetching it themselves rather than using a foreign node's DID.
592
+ */
593
+ nodeIdForHost(host) {
594
+ return this._nodeInfoIdentity?.host === host ? this._nodeInfoIdentity.nodeId : void 0;
595
+ }
554
596
  get lastActivationSkippedSpaceIds() {
555
597
  return [...this._lastActivationSkippedSpaceIds];
556
598
  }
@@ -610,23 +652,41 @@ var NodeUserAuthorization = class {
610
652
  const defaultNetworkId = this.defaultEncryptionNetworkId(address, chainId);
611
653
  const primarySpaceId2 = (0, import_sdk_core3.makePkhSpaceId)(address, chainId, this.spacePrefix);
612
654
  const secretsSpaceId = (0, import_sdk_core3.makePkhSpaceId)(address, chainId, "secrets");
613
- return {
614
- abilities: this.defaultActions,
615
- spaceId: primarySpaceId2,
616
- spaceAbilities: {
617
- [primarySpaceId2]: this.defaultActions,
618
- [secretsSpaceId]: {
619
- kv: {
620
- "vault/secrets/": [
621
- import_sdk_core3.KV.GET,
622
- import_sdk_core3.KV.PUT,
623
- import_sdk_core3.KV.DEL,
624
- import_sdk_core3.KV.LIST,
625
- import_sdk_core3.KV.METADATA
626
- ]
655
+ const primaryActions = this.includeAccountRegistryPermissions && this.spacePrefix === import_sdk_core3.ACCOUNT_REGISTRY_SPACE ? cloneAbilitiesMap(this.defaultActions) : this.defaultActions;
656
+ const spaceAbilities2 = {
657
+ [primarySpaceId2]: primaryActions,
658
+ [secretsSpaceId]: {
659
+ kv: {
660
+ "vault/secrets/": [
661
+ import_sdk_core3.KV.GET,
662
+ import_sdk_core3.KV.PUT,
663
+ import_sdk_core3.KV.DEL,
664
+ import_sdk_core3.KV.LIST,
665
+ import_sdk_core3.KV.METADATA
666
+ ]
667
+ }
668
+ }
669
+ };
670
+ if (this.includeAccountRegistryPermissions && this.spacePrefix !== import_sdk_core3.ACCOUNT_REGISTRY_SPACE) {
671
+ spaceAbilities2[(0, import_sdk_core3.makePkhSpaceId)(address, chainId, import_sdk_core3.ACCOUNT_REGISTRY_SPACE)] = (0, import_sdk_core3.resourceCapabilitiesToAbilitiesMap)(import_sdk_core3.ACCOUNT_MANIFEST_PERMISSIONS);
672
+ } else if (this.includeAccountRegistryPermissions) {
673
+ const accountAbilities = (0, import_sdk_core3.resourceCapabilitiesToAbilitiesMap)(
674
+ import_sdk_core3.ACCOUNT_MANIFEST_PERMISSIONS
675
+ );
676
+ for (const [service, paths] of Object.entries(accountAbilities)) {
677
+ const existingPaths = primaryActions[service] ?? (primaryActions[service] = {});
678
+ for (const [path, actions] of Object.entries(paths)) {
679
+ const existingActions = existingPaths[path] ?? (existingPaths[path] = []);
680
+ for (const action of actions) {
681
+ if (!existingActions.includes(action)) existingActions.push(action);
627
682
  }
628
683
  }
629
- },
684
+ }
685
+ }
686
+ return {
687
+ abilities: primaryActions,
688
+ spaceId: primarySpaceId2,
689
+ spaceAbilities: spaceAbilities2,
630
690
  rawAbilities: {
631
691
  [defaultNetworkId]: [DECRYPT_ACTION, NETWORK_CREATE_ACTION]
632
692
  }
@@ -1020,11 +1080,13 @@ var NodeUserAuthorization = class {
1020
1080
  this._tinyCloudSession = tinyCloudSession;
1021
1081
  this._address = address;
1022
1082
  this._chainId = chainId;
1083
+ const infoHost = this.primaryTinyCloudHost;
1023
1084
  const nodeInfo = await (0, import_sdk_core3.checkNodeInfo)(
1024
- this.primaryTinyCloudHost,
1085
+ infoHost,
1025
1086
  this.wasm.protocolVersion()
1026
1087
  );
1027
1088
  this._nodeFeatures = nodeInfo.features;
1089
+ this._nodeInfoIdentity = nodeInfo.nodeId ? { host: infoHost, nodeId: nodeInfo.nodeId } : void 0;
1028
1090
  for (const ext of this.extensions) {
1029
1091
  if (ext.afterSignIn) {
1030
1092
  await ext.afterSignIn(clientSession);
@@ -1190,11 +1252,13 @@ var NodeUserAuthorization = class {
1190
1252
  this._tinyCloudSession = tinyCloudSession;
1191
1253
  this._address = address;
1192
1254
  this._chainId = chainId;
1255
+ const infoHost = this.primaryTinyCloudHost;
1193
1256
  const nodeInfo = await (0, import_sdk_core3.checkNodeInfo)(
1194
- this.primaryTinyCloudHost,
1257
+ infoHost,
1195
1258
  this.wasm.protocolVersion()
1196
1259
  );
1197
1260
  this._nodeFeatures = nodeInfo.features;
1261
+ this._nodeInfoIdentity = nodeInfo.nodeId ? { host: infoHost, nodeId: nodeInfo.nodeId } : void 0;
1198
1262
  for (const ext of this.extensions) {
1199
1263
  if (ext.afterSignIn) {
1200
1264
  await ext.afterSignIn(clientSession);
@@ -1203,6 +1267,398 @@ var NodeUserAuthorization = class {
1203
1267
  await this.ensureSpaceExists();
1204
1268
  return clientSession;
1205
1269
  }
1270
+ /**
1271
+ * End-to-end sign-in via OpenKey `authorizeTinyCloud()` (Sol CRITICAL-4).
1272
+ *
1273
+ * This is the production entry point that wires
1274
+ * {@link prepareSessionForSigning} → OpenKey `authorizeTinyCloud()` →
1275
+ * {@link signInWithOpenKeyResult} into one call. Consumers pass in a
1276
+ * caller-provided `authorizeFn` (typically a thin wrapper around
1277
+ * `openkey.authorizeTinyCloud()`) so this method has no direct
1278
+ * dependency on the OpenKey SDK — it works with any transport that
1279
+ * conforms to the versioned protocol.
1280
+ *
1281
+ * The `authorizeFn` MUST:
1282
+ * 1. Send the prepared SIWE bytes to OpenKey.
1283
+ * 2. Return the exact `TinyCloudAuthorizationResultV1` shape (with
1284
+ * `signedMessage` — the bytes the signature actually verifies
1285
+ * against — plus `selectedActionKeys` and `permissions`).
1286
+ *
1287
+ * NodeUserAuthorization then completes the session with `signedMessage`
1288
+ * (never the prepared bytes), verifying the signature, the immutable
1289
+ * SIWE fields, and every subset invariant enforced by
1290
+ * {@link signInWithOpenKeyResult}. This preserves the exact-byte
1291
+ * guarantee: if the user did NOT narrow, the returned `signedMessage`
1292
+ * equals the prepared SIWE byte-for-byte; if the user narrowed, only
1293
+ * the ReCap block differs and every immutable header field survives.
1294
+ *
1295
+ * Legacy exact-byte {@link signIn} callers are unaffected — this method
1296
+ * is opt-in.
1297
+ *
1298
+ * @param authorizeFn Callback that invokes the versioned OpenKey
1299
+ * `authorizeTinyCloud()` protocol and returns the result. Receives
1300
+ * the prepared SIWE bytes plus a `host` hint the widget should bind
1301
+ * into its /authorize-sign-prepare context.
1302
+ * @param options Optional overrides for the OpenKey request.
1303
+ */
1304
+ async signInWithOpenKey(authorizeFn, options) {
1305
+ if (options?.host !== void 0) {
1306
+ const overrideHost = options.host.trim();
1307
+ if (!overrideHost) {
1308
+ throw new Error("OpenKey authorization host must not be empty");
1309
+ }
1310
+ this.tinycloudHosts = [overrideHost];
1311
+ }
1312
+ const signingAddress = (0, import_sdk_core3.canonicalizeAddress)(await this.signer.getAddress());
1313
+ const signingChainId = await this.signer.getChainId();
1314
+ await this.resolveTinyCloudHostsForSignIn(signingAddress, signingChainId);
1315
+ const { prepared, keyId, jwk } = await this.prepareSessionForSigning();
1316
+ const presentation = this.buildPresentationEnvelope(options?.reason);
1317
+ const hostHint = this.primaryTinyCloudHost;
1318
+ const result = await authorizeFn({
1319
+ protocolVersion: 1,
1320
+ siwe: prepared.siwe,
1321
+ jwk,
1322
+ host: hostHint,
1323
+ keyId: options?.openkeyKeyId,
1324
+ presentation
1325
+ });
1326
+ return this.signInWithOpenKeyResult(result, prepared, keyId, jwk);
1327
+ }
1328
+ /**
1329
+ * Build a `CapabilityPresentationEnvelopeV1` from `this._manifest`. The
1330
+ * envelope is display-only — the OpenKey widget renders it under an
1331
+ * honest trust label, and the server /authorize-sign-prepare route
1332
+ * decides whether to upgrade the label to `origin-bound` by fetching
1333
+ * the well-known manifest and comparing its digest to
1334
+ * `manifestDigest`.
1335
+ *
1336
+ * Returns `undefined` when no manifest is set — the widget renders as
1337
+ * "no manifest supplied" (unsigned).
1338
+ *
1339
+ * The digest is a deterministic SHA-256 over canonical JSON of the
1340
+ * first manifest payload; OpenKey parses the fetched well-known manifest
1341
+ * and applies the same canonicalization before comparing digests.
1342
+ */
1343
+ buildPresentationEnvelope(reason) {
1344
+ const manifests = Array.isArray(this._manifest) ? this._manifest : this._manifest ? [this._manifest] : [];
1345
+ if (manifests.length === 0 && !reason) {
1346
+ return void 0;
1347
+ }
1348
+ const primary = manifests[0];
1349
+ let displayName;
1350
+ let manifestId;
1351
+ let manifestDigest;
1352
+ const envelopeManifests = [];
1353
+ for (const m of manifests) {
1354
+ envelopeManifests.push({
1355
+ name: m.name,
1356
+ appId: m.app_id,
1357
+ // Deep-copy to prevent later mutations from affecting the digest.
1358
+ payload: JSON.parse(JSON.stringify(m))
1359
+ });
1360
+ }
1361
+ if (primary) {
1362
+ displayName = primary.name;
1363
+ manifestId = primary.app_id;
1364
+ manifestDigest = canonicalOpenKeyManifestSha256Hex(primary);
1365
+ }
1366
+ return {
1367
+ protocolVersion: 1,
1368
+ displayName,
1369
+ reason: reason && reason.length > 0 ? reason : void 0,
1370
+ manifestId,
1371
+ manifestDigest,
1372
+ manifests: envelopeManifests.length > 0 ? envelopeManifests : void 0
1373
+ };
1374
+ }
1375
+ /**
1376
+ * Complete sign-in with a versioned OpenKey authorization result.
1377
+ *
1378
+ * Where `signInWithPreparedSession` completes with a signature over the
1379
+ * ORIGINAL prepared SIWE, this method completes with the SIWE bytes the
1380
+ * OpenKey widget actually presented and signed (`signedMessage`). It
1381
+ * exists because the versioned authorizeTinyCloud protocol lets the user
1382
+ * narrow capabilities; the returned bytes may differ from the original
1383
+ * request and are the only ones the signature actually verifies against.
1384
+ *
1385
+ * The `result` parameter is validated at the trust boundary via
1386
+ * `validateAuthorizationResultV1` (in sdk-core). Callers should validate
1387
+ * BEFORE calling this method so unknown/missing fields are rejected
1388
+ * before any session state is created.
1389
+ *
1390
+ * Validation performed here (defence-in-depth: even a "valid" v1 payload
1391
+ * must clear these checks before any session state is created):
1392
+ * 1. `result.address` matches the local signer's address.
1393
+ * 2. `result.signature` recovers to `result.address` when verified against
1394
+ * `result.signedMessage`.
1395
+ * 3. Every immutable SIWE header field (domain, address, URI, version,
1396
+ * chainId, nonce, issuedAt) in the original prepared SIWE is preserved
1397
+ * byte-for-byte in `result.signedMessage`. `statement` is NOT in this
1398
+ * list because the WASM renders it from the ReCap contents; it MUST
1399
+ * change when the ReCap is narrowed.
1400
+ * 4. Every capability in `result.signedMessage`'s ReCap block was present
1401
+ * in the original prepared SIWE — narrowing OK, broadening rejected.
1402
+ * 5. Every `result.selectedActionKeys` entry corresponds to a
1403
+ * (resource, action) pair in the returned SIWE.
1404
+ */
1405
+ async signInWithOpenKeyResult(result, prepared, keyId, jwk) {
1406
+ if (result.protocolVersion !== 1) {
1407
+ throw new Error(`Unsupported OpenKey protocol version ${result.protocolVersion}`);
1408
+ }
1409
+ if (!result.signedMessage) {
1410
+ throw new Error("OpenKey result must include signedMessage");
1411
+ }
1412
+ if (!prepared.siwe) {
1413
+ throw new Error(
1414
+ "signInWithOpenKeyResult requires prepared.siwe (the SDK-generated SIWE the sign-in was proposed with)"
1415
+ );
1416
+ }
1417
+ const expectedAddress = (0, import_sdk_core3.canonicalizeAddress)(await this.signer.getAddress());
1418
+ if ((0, import_sdk_core3.canonicalizeAddress)(result.address) !== expectedAddress) {
1419
+ throw new Error(
1420
+ `OpenKey returned signature from ${result.address} but expected ${expectedAddress}`
1421
+ );
1422
+ }
1423
+ let parsedSignedSiwe;
1424
+ try {
1425
+ parsedSignedSiwe = new import_siwe.SiweMessage(result.signedMessage);
1426
+ } catch (err) {
1427
+ throw new Error(
1428
+ `OpenKey signedMessage is not a parseable SIWE: ${err.message}`
1429
+ );
1430
+ }
1431
+ const verifyResult = await parsedSignedSiwe.verify(
1432
+ { signature: result.signature },
1433
+ { suppressExceptions: true }
1434
+ );
1435
+ if (!verifyResult.success) {
1436
+ const errData = verifyResult.error;
1437
+ const type = errData && typeof errData === "object" && "type" in errData ? errData.type : void 0;
1438
+ const details = errData && typeof errData === "object" && "expected" in errData ? ` (expected=${errData.expected}, received=${errData.received})` : "";
1439
+ throw new Error(
1440
+ `OpenKey signature verification failed${type ? `: ${type}` : ""}${details}`
1441
+ );
1442
+ }
1443
+ if ((0, import_sdk_core3.canonicalizeAddress)(parsedSignedSiwe.address) !== expectedAddress) {
1444
+ throw new Error(
1445
+ `OpenKey signedMessage.address (${parsedSignedSiwe.address}) does not match local signer address (${expectedAddress})`
1446
+ );
1447
+ }
1448
+ const originalHasRecap = /(?:^|\n)-\s+urn:recap:/i.test(prepared.siwe);
1449
+ const originalFields = (0, import_sdk_core3.extractImmutableSiweFields)(prepared.siwe);
1450
+ const signedFields = (0, import_sdk_core3.extractImmutableSiweFields)(result.signedMessage);
1451
+ const changedFields = (0, import_sdk_core3.diffImmutableSiweFields)(originalFields, signedFields, {
1452
+ originalHasRecap
1453
+ });
1454
+ if (changedFields.length > 0) {
1455
+ throw new Error(
1456
+ `OpenKey signedMessage altered immutable SIWE fields: ${changedFields.join(", ")}`
1457
+ );
1458
+ }
1459
+ let originalCaps;
1460
+ let signedCaps;
1461
+ try {
1462
+ originalCaps = (0, import_sdk_core3.extractRecapAttenuations)(prepared.siwe);
1463
+ } catch (err) {
1464
+ throw new Error(
1465
+ `Failed to decode prepared SIWE recap: ${err.message}`
1466
+ );
1467
+ }
1468
+ try {
1469
+ signedCaps = (0, import_sdk_core3.extractRecapAttenuations)(result.signedMessage);
1470
+ } catch (err) {
1471
+ throw new Error(
1472
+ `Failed to decode OpenKey signedMessage recap: ${err.message}`
1473
+ );
1474
+ }
1475
+ const unauthorized = (0, import_sdk_core3.unauthorizedRecapCapabilities)(signedCaps, originalCaps);
1476
+ if (unauthorized.length > 0) {
1477
+ const previewLimit = 5;
1478
+ const preview = unauthorized.slice(0, previewLimit).map((u) => `${u.resource}::${u.action}`).join(", ");
1479
+ const overflow = unauthorized.length > previewLimit ? ` (and ${unauthorized.length - previewLimit} more)` : "";
1480
+ throw new Error(
1481
+ `OpenKey signedMessage broadened authorization beyond the original request: ${preview}${overflow}`
1482
+ );
1483
+ }
1484
+ if (!result.signedMessage.includes(prepared.spaceId)) {
1485
+ throw new Error(
1486
+ `OpenKey signedMessage does not reference the expected spaceId ${prepared.spaceId}`
1487
+ );
1488
+ }
1489
+ const grantedPairs = /* @__PURE__ */ new Set();
1490
+ const grantedFourPartIndex = /* @__PURE__ */ new Map();
1491
+ for (const [resource, actions] of Object.entries(signedCaps)) {
1492
+ const { space, path } = (0, import_sdk_core3.parseCanonicalRecapResource)(resource);
1493
+ for (const action of Object.keys(actions)) {
1494
+ grantedPairs.add(`${resource}\0${action}`);
1495
+ const slashIdx = action.indexOf("/");
1496
+ const service = slashIdx > 0 ? action.slice(0, slashIdx) : "";
1497
+ if (service) {
1498
+ grantedFourPartIndex.set(
1499
+ `${service}\0${space}\0${path}\0${action}`,
1500
+ `${resource}\0${action}`
1501
+ );
1502
+ }
1503
+ }
1504
+ }
1505
+ const isStructurallyRequired = (action) => action === "tinycloud.capabilities/read" || action === "capabilities/read";
1506
+ const nonRequiredGrantedPairs = /* @__PURE__ */ new Set();
1507
+ for (const pair of grantedPairs) {
1508
+ const idx = pair.indexOf("\0");
1509
+ const action = idx >= 0 ? pair.slice(idx + 1) : pair;
1510
+ if (!isStructurallyRequired(action)) {
1511
+ nonRequiredGrantedPairs.add(pair);
1512
+ }
1513
+ }
1514
+ const allSignedGrantedPairs = new Set(grantedPairs);
1515
+ const seenRawKeys = /* @__PURE__ */ new Set();
1516
+ for (const rawKey of result.selectedActionKeys) {
1517
+ if (seenRawKeys.has(rawKey)) {
1518
+ throw new Error(
1519
+ `OpenKey selectedActionKeys contains duplicate entry ${rawKey}`
1520
+ );
1521
+ }
1522
+ seenRawKeys.add(rawKey);
1523
+ }
1524
+ const selectedResourceActionPairs = /* @__PURE__ */ new Set();
1525
+ for (const rawKey of result.selectedActionKeys) {
1526
+ const parts = rawKey.split("\0");
1527
+ if (parts.length !== 4) {
1528
+ throw new Error(
1529
+ `OpenKey selectedActionKeys entry is malformed (expected canonical 4-part service\\0space\\0path\\0ability): ${rawKey}`
1530
+ );
1531
+ }
1532
+ const canonical = grantedFourPartIndex.get(rawKey);
1533
+ if (!canonical) {
1534
+ throw new Error(
1535
+ `OpenKey selectedActionKeys entry ${rawKey} is not covered by any granted capability`
1536
+ );
1537
+ }
1538
+ selectedResourceActionPairs.add(canonical);
1539
+ }
1540
+ if (nonRequiredGrantedPairs.size > 0) {
1541
+ const missingPairs = [];
1542
+ for (const pair of nonRequiredGrantedPairs) {
1543
+ if (!selectedResourceActionPairs.has(pair)) {
1544
+ missingPairs.push(pair);
1545
+ }
1546
+ }
1547
+ if (missingPairs.length > 0) {
1548
+ const previewLimit = 5;
1549
+ const preview = missingPairs.slice(0, previewLimit).map((p) => p.replace("\0", "::")).join(", ");
1550
+ const overflow = missingPairs.length > previewLimit ? ` (and ${missingPairs.length - previewLimit} more)` : "";
1551
+ throw new Error(
1552
+ `OpenKey selectedActionKeys is missing entries for signedMessage capabilities: ${preview}${overflow}`
1553
+ );
1554
+ }
1555
+ }
1556
+ if (allSignedGrantedPairs.size > 0 && result.permissions.length === 0) {
1557
+ throw new Error(
1558
+ "OpenKey permissions is empty but signedMessage carries capabilities \u2014 refusing to accept a session without an authoritative grant list"
1559
+ );
1560
+ }
1561
+ const permissionCanonicalKeys = /* @__PURE__ */ new Set();
1562
+ const permissionPairsCovered = /* @__PURE__ */ new Set();
1563
+ for (const perm of result.permissions) {
1564
+ const { service, space, path, actions } = perm;
1565
+ if (typeof service !== "string" || typeof space !== "string" || typeof path !== "string") {
1566
+ throw new Error(
1567
+ `OpenKey permissions entry has non-string service/space/path (service=${service}, space=${space}, path=${path})`
1568
+ );
1569
+ }
1570
+ if (!Array.isArray(actions) || actions.length === 0) {
1571
+ throw new Error(
1572
+ `OpenKey permissions entry (${service} ${space} ${path}) has empty actions \u2014 a capability-bearing entry must list at least one action`
1573
+ );
1574
+ }
1575
+ const canonicalKey = `${service}\0${space}\0${path}`;
1576
+ if (permissionCanonicalKeys.has(canonicalKey)) {
1577
+ throw new Error(
1578
+ `OpenKey permissions has duplicate entry for (${service} ${space} ${path})`
1579
+ );
1580
+ }
1581
+ permissionCanonicalKeys.add(canonicalKey);
1582
+ let expectedResource;
1583
+ if (space.startsWith("tinycloud:")) {
1584
+ const shortService = service.startsWith("tinycloud.") ? service.slice("tinycloud.".length) : service;
1585
+ expectedResource = path ? `${space}/${shortService}/${path}` : `${space}/${shortService}`;
1586
+ } else {
1587
+ if (path) {
1588
+ throw new Error(
1589
+ `OpenKey permissions entry (${service} ${space} ${path}) carries a path on a non-tinycloud: resource URI, which is not a recognized encoding`
1590
+ );
1591
+ }
1592
+ expectedResource = space;
1593
+ }
1594
+ let matchedResource;
1595
+ if (signedCaps[expectedResource] !== void 0) {
1596
+ matchedResource = expectedResource;
1597
+ } else {
1598
+ throw new Error(
1599
+ `OpenKey permissions entry (${service} ${space} ${path}) claims grants not confirmed in signedMessage capabilities`
1600
+ );
1601
+ }
1602
+ const grantedActionsForResource = new Set(
1603
+ Object.keys(signedCaps[matchedResource])
1604
+ );
1605
+ for (const action of actions) {
1606
+ if (typeof action !== "string" || !action) {
1607
+ throw new Error(
1608
+ `OpenKey permissions entry (${service} ${space} ${path}) has non-string action ${String(action)}`
1609
+ );
1610
+ }
1611
+ if (!grantedActionsForResource.has(action)) {
1612
+ throw new Error(
1613
+ `OpenKey permissions entry (${service} ${space} ${path}) claims action ${action} not present in signedMessage capabilities`
1614
+ );
1615
+ }
1616
+ permissionPairsCovered.add(`${matchedResource}\0${action}`);
1617
+ }
1618
+ }
1619
+ if (allSignedGrantedPairs.size > 0) {
1620
+ const missingFromPerms = [];
1621
+ for (const pair of allSignedGrantedPairs) {
1622
+ if (!permissionPairsCovered.has(pair)) {
1623
+ missingFromPerms.push(pair);
1624
+ }
1625
+ }
1626
+ if (missingFromPerms.length > 0) {
1627
+ const previewLimit = 5;
1628
+ const preview = missingFromPerms.slice(0, previewLimit).map((p) => p.replace("\0", "::")).join(", ");
1629
+ const overflow = missingFromPerms.length > previewLimit ? ` (and ${missingFromPerms.length - previewLimit} more)` : "";
1630
+ throw new Error(
1631
+ `OpenKey permissions is missing entries for signedMessage capabilities: ${preview}${overflow}`
1632
+ );
1633
+ }
1634
+ const extrasInPerms = [];
1635
+ for (const pair of permissionPairsCovered) {
1636
+ if (!allSignedGrantedPairs.has(pair)) {
1637
+ extrasInPerms.push(pair);
1638
+ }
1639
+ }
1640
+ if (extrasInPerms.length > 0) {
1641
+ const previewLimit = 5;
1642
+ const preview = extrasInPerms.slice(0, previewLimit).map((p) => p.replace("\0", "::")).join(", ");
1643
+ const overflow = extrasInPerms.length > previewLimit ? ` (and ${extrasInPerms.length - previewLimit} more)` : "";
1644
+ throw new Error(
1645
+ `OpenKey permissions contains entries not present in the signed authority: ${preview}${overflow}`
1646
+ );
1647
+ }
1648
+ }
1649
+ void nonRequiredGrantedPairs;
1650
+ return this.signInWithPreparedSession(
1651
+ {
1652
+ siwe: result.signedMessage,
1653
+ jwk: prepared.jwk,
1654
+ spaceId: prepared.spaceId,
1655
+ verificationMethod: prepared.verificationMethod
1656
+ },
1657
+ result.signature,
1658
+ keyId,
1659
+ jwk
1660
+ );
1661
+ }
1206
1662
  /**
1207
1663
  * Clear persisted session data.
1208
1664
  */
@@ -1697,6 +2153,9 @@ var DEFAULT_HOST = "https://node.tinycloud.xyz";
1697
2153
  var DEFAULT_ENCRYPTION_NETWORK_NAME = "default";
1698
2154
  var NETWORK_CREATE_ACTION2 = import_sdk_core8.ENCRYPTION.NETWORK_CREATE;
1699
2155
  var DECRYPT_ACTION2 = import_sdk_core8.ENCRYPTION.DECRYPT;
2156
+ var BOOTSTRAP_COMPLETION_MARKER_KEY = "system/bootstrap/complete";
2157
+ var BOOTSTRAP_COMPLETION_MARKER_VERSION = 1;
2158
+ var ACCEPTED_MARKER_VERSIONS = [BOOTSTRAP_COMPLETION_MARKER_VERSION];
1700
2159
  var NETWORK_ADMIN_TYPE = "tinycloud.encryption.network-admin/v1";
1701
2160
  function isSecretPayload(value) {
1702
2161
  return typeof value === "object" && value !== null && "value" in value && typeof value.value === "string" && "createdAt" in value && typeof value.createdAt === "string" && "updatedAt" in value && typeof value.updatedAt === "string";
@@ -2007,7 +2466,9 @@ function base64UrlDecode(value) {
2007
2466
  return new Uint8Array(bytes);
2008
2467
  }
2009
2468
  async function signJwtInputWithJwk(signingInput, jwk) {
2010
- const bytes = new TextEncoder().encode(signingInput);
2469
+ return signBytesWithJwk(new TextEncoder().encode(signingInput), jwk);
2470
+ }
2471
+ async function signBytesWithJwk(bytes, jwk) {
2011
2472
  try {
2012
2473
  const subtle = globalThis.crypto?.subtle;
2013
2474
  if (!subtle) {
@@ -2088,6 +2549,12 @@ var _TinyCloudNode = class _TinyCloudNode {
2088
2549
  this._baseSecrets = /* @__PURE__ */ new Map();
2089
2550
  this._secrets = /* @__PURE__ */ new Map();
2090
2551
  this.runtimePermissionGrants = [];
2552
+ /**
2553
+ * Owned space ids this sign-in has already confirmed are hosted. Consulted by
2554
+ * {@link ensureOwnedSpaceHostedById}; cleared on every {@link signIn} because
2555
+ * hosting is confirmed against the session that was active at the time.
2556
+ */
2557
+ this.confirmedHostedSpaceIds = /* @__PURE__ */ new Set();
2091
2558
  /**
2092
2559
  * True when the last signIn() detected an interactive signer and skipped
2093
2560
  * client-side bootstrap. Apps can read this to know whether bootstrap was
@@ -2096,9 +2563,15 @@ var _TinyCloudNode = class _TinyCloudNode {
2096
2563
  this._bootstrapSkipped = false;
2097
2564
  /**
2098
2565
  * Outcome of the last signIn()'s account-bootstrap attempt. `skipped` is
2099
- * true when bootstrap did not complete (interactive signer, auto-sign
2100
- * denied, or a bootstrap step failed); `reason` carries the cause so apps
2101
- * can surface a "finish account setup" call-to-action.
2566
+ * true when bootstrap did not complete in this invocation (interactive signer,
2567
+ * auto-bootstrap disabled, already provisioned, or a decision/provisioning
2568
+ * step failed). `already-provisioned` means the bootstrap was not run here;
2569
+ * it does not mean a prior provisioning attempt failed to complete.
2570
+ * `reason` carries the cause so apps can surface a "finish account setup"
2571
+ * call-to-action. `warnings` is
2572
+ * present when bootstrap completed, but one or more steps recovered from
2573
+ * an ambiguous failure (e.g. a KV batch write reconciled via per-space
2574
+ * fallback) — clean vs. recovered runs are otherwise byte-identical.
2102
2575
  */
2103
2576
  this._bootstrapStatus = {
2104
2577
  skipped: false
@@ -2278,7 +2751,7 @@ var _TinyCloudNode = class _TinyCloudNode {
2278
2751
  nonce: config.nonce,
2279
2752
  siweConfig: config.siweConfig,
2280
2753
  manifest: useBootstrapSignInRequest ? void 0 : config.manifest,
2281
- capabilityRequest: useBootstrapSignInRequest ? import_sdk_core8.BOOTSTRAP_SESSION_REQUESTS.default : config.capabilityRequest,
2754
+ capabilityRequest: useBootstrapSignInRequest ? config.includeAccountRegistryPermissions === false ? import_sdk_core8.BOOTSTRAP_DEFAULT_ONLY_SESSION_REQUEST : import_sdk_core8.BOOTSTRAP_SESSION_REQUESTS.default : config.capabilityRequest,
2282
2755
  includeAccountRegistryPermissions: useBootstrapSignInRequest ? false : config.includeAccountRegistryPermissions
2283
2756
  });
2284
2757
  this.tc = new import_sdk_core8.TinyCloud(this.auth, {
@@ -2484,6 +2957,7 @@ var _TinyCloudNode = class _TinyCloudNode {
2484
2957
  this._spaceService = void 0;
2485
2958
  this._serviceContext = void 0;
2486
2959
  this.runtimePermissionGrants = [];
2960
+ this.confirmedHostedSpaceIds.clear();
2487
2961
  await this.tc.signIn(options);
2488
2962
  this.syncResolvedHostFromAuth();
2489
2963
  const oldGraph = this._serviceGraph;
@@ -2520,6 +2994,7 @@ var _TinyCloudNode = class _TinyCloudNode {
2520
2994
  this._bootstrapSkipped = false;
2521
2995
  this._bootstrapStatus = { skipped: false };
2522
2996
  if (this.config.autoBootstrapAccount === false) {
2997
+ this._bootstrapStatus = { skipped: true, reason: "auto-bootstrap-disabled" };
2523
2998
  return false;
2524
2999
  }
2525
3000
  if (!this.auth || !this._address) {
@@ -2534,11 +3009,20 @@ var _TinyCloudNode = class _TinyCloudNode {
2534
3009
  return false;
2535
3010
  }
2536
3011
  const steps = (0, import_sdk_core8.bootstrapSteps)(this._address, this._chainId);
2537
- if (!await this.isFreshBootstrapAccount(steps)) {
2538
- return false;
2539
- }
2540
3012
  try {
2541
- await this.runAccountBootstrap(steps);
3013
+ const decision = await this.resolveBootstrapDecision(steps);
3014
+ if (decision.action === "skip") {
3015
+ this._bootstrapStatus = { skipped: true, reason: "already-provisioned" };
3016
+ return false;
3017
+ }
3018
+ const warnings = await this.runAccountBootstrap(steps, { mode: decision.mode });
3019
+ await this.writeBootstrapCompletionMarker(steps);
3020
+ if (warnings.length > 0) {
3021
+ this._bootstrapStatus = { skipped: false, warnings };
3022
+ this.notificationHandler.warning(
3023
+ `Account bootstrap completed with warnings: ${warnings.map((w) => `${w.stepId} (${w.kind}: ${w.code})`).join("; ")}`
3024
+ );
3025
+ }
2542
3026
  } catch (err) {
2543
3027
  const reason = err instanceof Error ? err.message : String(err);
2544
3028
  this._bootstrapSkipped = true;
@@ -2551,32 +3035,92 @@ var _TinyCloudNode = class _TinyCloudNode {
2551
3035
  }
2552
3036
  return true;
2553
3037
  }
2554
- async isFreshBootstrapAccount(steps) {
3038
+ async resolveBootstrapDecision(steps) {
2555
3039
  const enshrinedSpaceIds = /* @__PURE__ */ new Set();
2556
3040
  for (const step of steps) {
2557
3041
  if (step.kind === "session") {
2558
3042
  enshrinedSpaceIds.add(step.spaceId);
2559
3043
  }
2560
3044
  }
2561
- const skipped = this.auth.lastActivationSkippedSpaceIds;
3045
+ const auth = this.auth;
3046
+ if (!auth) {
3047
+ throw new Error("Account bootstrap requires an active wallet session");
3048
+ }
3049
+ const skipped = auth.lastActivationSkippedSpaceIds;
2562
3050
  if (skipped.some((spaceId) => enshrinedSpaceIds.has(spaceId))) {
2563
- return true;
3051
+ return { action: "run", mode: "fresh" };
2564
3052
  }
2565
3053
  try {
2566
- const indexed = await this.account.index.spaces.list();
2567
- if (indexed.ok && indexed.data.length === 0) {
2568
- return true;
3054
+ const accountSpaceId = this.ownedSpaceId(import_sdk_core8.ACCOUNT_REGISTRY_SPACE);
3055
+ const markerPermission = {
3056
+ service: "tinycloud.kv",
3057
+ space: accountSpaceId,
3058
+ path: BOOTSTRAP_COMPLETION_MARKER_KEY,
3059
+ actions: ["get"]
3060
+ };
3061
+ if (!this.hasRuntimePermissions([markerPermission])) {
3062
+ return { action: "run", mode: "repair" };
2569
3063
  }
2570
- } catch {
3064
+ const marker = await this.readBootstrapCompletionMarker();
3065
+ if (marker.ok) {
3066
+ if (this.isAcceptedBootstrapCompletionMarker(marker.data.data)) {
3067
+ return { action: "skip" };
3068
+ }
3069
+ return { action: "run", mode: "repair" };
3070
+ }
3071
+ if (marker.error.code === import_sdk_core8.ErrorCodes.KV_NOT_FOUND && this.markerReadIsUnhostedSpace(marker.error.meta)) {
3072
+ return { action: "run", mode: "fresh" };
3073
+ }
3074
+ } catch (err) {
3075
+ const reason = err instanceof Error ? err.message : String(err);
3076
+ this.notificationHandler.warning(
3077
+ `Account bootstrap decision failed; attempting one bounded repair: ${reason}`
3078
+ );
3079
+ console.warn(`[TinyCloudNode] account bootstrap decision failed: ${reason}`);
3080
+ return { action: "run", mode: "repair" };
2571
3081
  }
2572
- try {
2573
- const spaces = await this.account.spaces.list();
2574
- return spaces.ok && spaces.data.length === 0;
2575
- } catch {
2576
- return false;
3082
+ return { action: "run", mode: "repair" };
3083
+ }
3084
+ async readBootstrapCompletionMarker() {
3085
+ return this.kvForSpace(this.ownedSpaceId(import_sdk_core8.ACCOUNT_REGISTRY_SPACE)).get(
3086
+ BOOTSTRAP_COMPLETION_MARKER_KEY
3087
+ );
3088
+ }
3089
+ async writeBootstrapCompletionMarker(steps) {
3090
+ if (!this._address) {
3091
+ throw new Error("Bootstrap completion marker requires an active wallet session");
3092
+ }
3093
+ const canonicalStepIds = new Set(
3094
+ (0, import_sdk_core8.bootstrapSteps)(this._address, this._chainId).map((step) => step.id)
3095
+ );
3096
+ const providedStepIds = new Set(steps.map((step) => step.id));
3097
+ if (canonicalStepIds.size !== providedStepIds.size || [...canonicalStepIds].some((stepId) => !providedStepIds.has(stepId))) {
3098
+ throw new Error("Bootstrap completion marker requires the canonical bootstrap step set");
3099
+ }
3100
+ const marker = {
3101
+ v: BOOTSTRAP_COMPLETION_MARKER_VERSION,
3102
+ stepIds: [...canonicalStepIds],
3103
+ completedAt: (/* @__PURE__ */ new Date()).toISOString()
3104
+ };
3105
+ const written = await this.kvForSpace(this.ownedSpaceId(import_sdk_core8.ACCOUNT_REGISTRY_SPACE)).put(
3106
+ BOOTSTRAP_COMPLETION_MARKER_KEY,
3107
+ marker
3108
+ );
3109
+ if (!written.ok) {
3110
+ throw new Error(`Failed to write bootstrap completion marker: ${written.error.message}`);
2577
3111
  }
2578
3112
  }
2579
- async runAccountBootstrap(steps) {
3113
+ isAcceptedBootstrapCompletionMarker(value) {
3114
+ if (typeof value !== "object" || value === null) return false;
3115
+ const entries = Object.entries(value);
3116
+ const markerValue = (name) => entries.find(([key]) => key === name)?.[1];
3117
+ const version = markerValue("v");
3118
+ return typeof version === "number" && Number.isInteger(version) && ACCEPTED_MARKER_VERSIONS.some((acceptedVersion) => acceptedVersion === version);
3119
+ }
3120
+ markerReadIsUnhostedSpace(meta) {
3121
+ return meta?.status === 404;
3122
+ }
3123
+ async runAccountBootstrap(steps, { mode } = { mode: "fresh" }) {
2580
3124
  if (!this.auth || !this._address) {
2581
3125
  throw new Error("Account bootstrap requires an active wallet session");
2582
3126
  }
@@ -2587,6 +3131,7 @@ var _TinyCloudNode = class _TinyCloudNode {
2587
3131
  const auth = this.auth;
2588
3132
  const sessions = /* @__PURE__ */ new Map();
2589
3133
  const rawAbilitiesBySpace = /* @__PURE__ */ new Map();
3134
+ const warnings = [];
2590
3135
  const primarySession = auth.tinyCloudSession;
2591
3136
  const defaultSpaceId = this.ownedSpaceId("default");
2592
3137
  const canReusePrimaryBootstrapSession = primarySession?.spaceId === defaultSpaceId && auth.capabilityRequest === import_sdk_core8.BOOTSTRAP_SESSION_REQUESTS.default;
@@ -2648,32 +3193,44 @@ var _TinyCloudNode = class _TinyCloudNode {
2648
3193
  }
2649
3194
  }
2650
3195
  if (step.kind === "seed-spaces") {
2651
- for (const space of step.spaces) {
2652
- const registered = await this.account.spaces.register({
3196
+ const registered = await this.account.spaces.registerBatch(
3197
+ step.spaces.map((space) => ({
2653
3198
  spaceId: space.spaceId,
2654
3199
  name: space.name,
2655
3200
  ownerDid: this.did,
2656
3201
  type: "owned",
2657
3202
  permissions: ["*"],
2658
3203
  status: "active"
3204
+ }))
3205
+ );
3206
+ if (!registered.ok) {
3207
+ throw new Error(
3208
+ `Failed to seed account spaces: ${registered.error.message}`
3209
+ );
3210
+ }
3211
+ const batchError = registered.data.recoveredFromBatchError;
3212
+ if (batchError) {
3213
+ warnings.push({
3214
+ stepId: step.id,
3215
+ kind: "batch-write-reconciled",
3216
+ code: batchError.code,
3217
+ message: batchError.message
2659
3218
  });
2660
- if (!registered.ok) {
2661
- throw new Error(
2662
- `Failed to seed account space ${space.spaceId}: ${registered.error.message}`
2663
- );
2664
- }
2665
3219
  }
2666
3220
  }
2667
3221
  if (step.kind === "seed-applications") {
2668
3222
  const registered = await this.account.applications.register(
2669
- step.manifests.length > 0 ? [...step.manifests] : import_sdk_core8.TINYCLOUD_SECRETS_BOOTSTRAP_MANIFEST
3223
+ step.manifests.length > 0 ? [...step.manifests] : import_sdk_core8.TINYCLOUD_SECRETS_BOOTSTRAP_MANIFEST,
3224
+ // The write is an INSERT OR REPLACE and is safe to repeat during a
3225
+ // repair, so skip the manifest-hash pre-read.
3226
+ { assumeUnregistered: true }
2670
3227
  );
2671
3228
  if (!registered.ok) {
2672
3229
  throw new Error(`Failed to seed bootstrap applications: ${registered.error.message}`);
2673
3230
  }
2674
3231
  }
2675
3232
  if (step.kind === "encryption-network-create") {
2676
- await this.ensureEncryptionNetwork(step.networkId);
3233
+ await this.ensureEncryptionNetwork(step.networkId, { assumeMissing: mode === "fresh" });
2677
3234
  }
2678
3235
  if (step.kind === "secret-records-schema") {
2679
3236
  const db = this.sqlForSpace(step.spaceId).db(step.database);
@@ -2693,6 +3250,7 @@ var _TinyCloudNode = class _TinyCloudNode {
2693
3250
  }
2694
3251
  }
2695
3252
  }
3253
+ return warnings;
2696
3254
  }
2697
3255
  registerBootstrapRuntimeGrant(session, request, rawAbilities) {
2698
3256
  const operations = [];
@@ -2952,6 +3510,22 @@ var _TinyCloudNode = class _TinyCloudNode {
2952
3510
  await this.ensureEncryptionNetwork(networkId);
2953
3511
  }
2954
3512
  }
3513
+ /**
3514
+ * Ensure one of this user's owned spaces is hosted, at most once per space
3515
+ * per sign-in.
3516
+ *
3517
+ * Account bootstrap funnels six calls through here (via
3518
+ * `AccountService.ensureAccountSpaceHosted`), and each one re-submits
3519
+ * `activateSessionWithHost` with the *primary* session. That session's recap
3520
+ * covers only `default` and `secrets`, so the account space appears in
3521
+ * neither `activated` nor `skipped` and the guard below returns immediately
3522
+ * — the repeat calls cannot change the outcome, they only cost round trips.
3523
+ *
3524
+ * The memo is per sign-in ({@link confirmedHostedSpaceIds} is cleared in
3525
+ * `signIn`) and only records confirmed successes. A wrong skip is
3526
+ * self-revealing: the next write to the space fails immediately and loudly
3527
+ * with `404 Space not found`.
3528
+ */
2955
3529
  async ensureOwnedSpaceHostedById(spaceId) {
2956
3530
  if (!this.auth) {
2957
3531
  throw new Error("Owned space hosting requires wallet mode");
@@ -2960,12 +3534,16 @@ var _TinyCloudNode = class _TinyCloudNode {
2960
3534
  if (!session) {
2961
3535
  throw new Error("Owned space hosting requires an active session");
2962
3536
  }
3537
+ if (this.confirmedHostedSpaceIds.has(spaceId)) {
3538
+ return;
3539
+ }
2963
3540
  const host = this.hosts[0] ?? this.config.host;
2964
3541
  if (!host) {
2965
3542
  throw new Error("Owned space hosting requires a TinyCloud host");
2966
3543
  }
2967
3544
  const activation = await (0, import_sdk_core8.activateSessionWithHost)(host, session.delegationHeader);
2968
3545
  if (activation.success && !activation.skipped?.includes(spaceId)) {
3546
+ this.confirmedHostedSpaceIds.add(spaceId);
2969
3547
  return;
2970
3548
  }
2971
3549
  if (!activation.success && activation.status !== 404) {
@@ -2984,6 +3562,7 @@ var _TinyCloudNode = class _TinyCloudNode {
2984
3562
  `Failed to activate session after creating owned space ${spaceId}: ${retry.error ?? "space was skipped"}`
2985
3563
  );
2986
3564
  }
3565
+ this.confirmedHostedSpaceIds.add(spaceId);
2987
3566
  }
2988
3567
  /**
2989
3568
  * Host one of this user's owned spaces by name (e.g. `"applications"`).
@@ -3299,6 +3878,7 @@ var _TinyCloudNode = class _TinyCloudNode {
3299
3878
  this._delegationManager = stagedGraph.delegationManager;
3300
3879
  this._spaceService = stagedGraph.spaceService;
3301
3880
  this._serviceGraph = stagedGraph.graph;
3881
+ this._activeServiceSession = serviceSession;
3302
3882
  this._baseSecrets = /* @__PURE__ */ new Map();
3303
3883
  this._secrets = /* @__PURE__ */ new Map();
3304
3884
  this._publicKV = void 0;
@@ -3306,6 +3886,7 @@ var _TinyCloudNode = class _TinyCloudNode {
3306
3886
  this.tc = stagedGraph.core;
3307
3887
  this._recapOperationsCache = stagedPrimary.cache;
3308
3888
  this.runtimePermissionGrants = stagedPrimary.grants;
3889
+ this.confirmedHostedSpaceIds.clear();
3309
3890
  if (this.auth) {
3310
3891
  this.auth.installRestoredSession(stagedManager, stagedTcSession, [stagedHost]);
3311
3892
  this._restoredTcSession = void 0;
@@ -3615,6 +4196,28 @@ var _TinyCloudNode = class _TinyCloudNode {
3615
4196
  currentTinyCloudSession() {
3616
4197
  return this.auth?.tinyCloudSession ?? this._restoredTcSession;
3617
4198
  }
4199
+ /**
4200
+ * Mint a multi-capability invocation through the established session key.
4201
+ * Callers receive only the signed headers; session key material remains
4202
+ * owned by the session manager and is never part of the caller contract.
4203
+ */
4204
+ invokeAny(entries, facts) {
4205
+ const session = this.currentTinyCloudSession() ?? this._activeServiceSession;
4206
+ if (session === void 0) throw new Error("Not signed in. Call signIn() first.");
4207
+ return this._serviceGraph.invokeAny(session, entries, facts);
4208
+ }
4209
+ /** Sign protocol bytes with the established in-memory session key. */
4210
+ async signSessionBytes(bytes) {
4211
+ const session = this.currentTinyCloudSession() ?? this._activeServiceSession;
4212
+ if (session === void 0) throw new Error("Not signed in. Call signIn() first.");
4213
+ return signBytesWithJwk(bytes, session.jwk);
4214
+ }
4215
+ /** Bind a session-signed invocation to the canonical service audience. */
4216
+ async bindInvocationAudience(authorization, audience) {
4217
+ const session = this.currentTinyCloudSession() ?? this._activeServiceSession;
4218
+ if (session === void 0) throw new Error("Not signed in. Call signIn() first.");
4219
+ return rewriteInvocationAudience(authorization, audience, session.jwk);
4220
+ }
3618
4221
  /**
3619
4222
  * Connect a wallet to upgrade from session-only mode to wallet mode.
3620
4223
  *
@@ -3676,7 +4279,7 @@ var _TinyCloudNode = class _TinyCloudNode {
3676
4279
  nonce: this.config.nonce,
3677
4280
  siweConfig: this.config.siweConfig,
3678
4281
  manifest: useBootstrapSignInRequest ? void 0 : this.config.manifest,
3679
- capabilityRequest: useBootstrapSignInRequest ? import_sdk_core8.BOOTSTRAP_SESSION_REQUESTS.default : this.config.capabilityRequest,
4282
+ capabilityRequest: useBootstrapSignInRequest ? this.config.includeAccountRegistryPermissions === false ? import_sdk_core8.BOOTSTRAP_DEFAULT_ONLY_SESSION_REQUEST : import_sdk_core8.BOOTSTRAP_SESSION_REQUESTS.default : this.config.capabilityRequest,
3680
4283
  includeAccountRegistryPermissions: useBootstrapSignInRequest ? false : this.config.includeAccountRegistryPermissions
3681
4284
  });
3682
4285
  this.tc = new import_sdk_core8.TinyCloud(this.auth, {
@@ -3729,7 +4332,7 @@ var _TinyCloudNode = class _TinyCloudNode {
3729
4332
  nonce: this.config.nonce,
3730
4333
  siweConfig: this.config.siweConfig,
3731
4334
  manifest: useBootstrapSignInRequest ? void 0 : this.config.manifest,
3732
- capabilityRequest: useBootstrapSignInRequest ? import_sdk_core8.BOOTSTRAP_SESSION_REQUESTS.default : this.config.capabilityRequest,
4335
+ capabilityRequest: useBootstrapSignInRequest ? this.config.includeAccountRegistryPermissions === false ? import_sdk_core8.BOOTSTRAP_DEFAULT_ONLY_SESSION_REQUEST : import_sdk_core8.BOOTSTRAP_SESSION_REQUESTS.default : this.config.capabilityRequest,
3733
4336
  includeAccountRegistryPermissions: useBootstrapSignInRequest ? false : this.config.includeAccountRegistryPermissions
3734
4337
  });
3735
4338
  this.tc = new import_sdk_core8.TinyCloud(this.auth, {
@@ -3796,6 +4399,7 @@ var _TinyCloudNode = class _TinyCloudNode {
3796
4399
  if (this._serviceContext) {
3797
4400
  const spaceScopedContext = this._serviceGraph.track(new import_sdk_core8.ServiceContext({
3798
4401
  invoke: this._serviceContext.invoke,
4402
+ invokeAny: this._serviceContext.invokeAny,
3799
4403
  fetch: this._serviceContext.fetch,
3800
4404
  hosts: this._serviceContext.hosts,
3801
4405
  telemetry: this.config.telemetry
@@ -3888,7 +4492,17 @@ var _TinyCloudNode = class _TinyCloudNode {
3888
4492
  verifyNodeSignature: (nodeId, message, signature) => (0, import_sdk_core8.verifyDidKeyEd25519Signature)(nodeId, message, signature)
3889
4493
  };
3890
4494
  }
4495
+ /**
4496
+ * The node DID for {@link config.host}.
4497
+ *
4498
+ * Sign-in already performs `GET /info` for the protocol-version check, and
4499
+ * that response carries `nodeId`, so reuse it rather than fetching `/info` a
4500
+ * second time. Only a value recorded for this exact host qualifies; older
4501
+ * nodes that omit `nodeId` fall through to the fetch.
4502
+ */
3891
4503
  async fetchNodeId() {
4504
+ const cached = this.auth?.nodeIdForHost(this.config.host);
4505
+ if (cached) return cached;
3892
4506
  const response = await fetch(`${this.config.host}/info`);
3893
4507
  if (!response.ok) {
3894
4508
  throw new Error(`Failed to fetch node info: HTTP ${response.status}`);
@@ -4451,6 +5065,7 @@ var _TinyCloudNode = class _TinyCloudNode {
4451
5065
  shareUrl: input.shareUrl,
4452
5066
  documentName: input.documentName,
4453
5067
  jti: base64UrlEncode(crypto.getRandomValues(new Uint8Array(16))),
5068
+ idempotencyKey: input.idempotencyKey,
4454
5069
  expiresAt: input.expiresAt
4455
5070
  };
4456
5071
  const requestBodyDigest = base64UrlEncode(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode((0, import_sdk_core8.canonicalizeEncryptionJson)(body)))));
@@ -4660,6 +5275,13 @@ var _TinyCloudNode = class _TinyCloudNode {
4660
5275
  body
4661
5276
  )
4662
5277
  });
5278
+ if (response.status === 409) {
5279
+ const existing = await this.getEncryptionNetwork(networkId);
5280
+ if (existing) return existing;
5281
+ throw new Error(
5282
+ `Encryption network ${networkId} already exists but could not be read back`
5283
+ );
5284
+ }
4663
5285
  if (!response.ok) {
4664
5286
  throw new Error(
4665
5287
  `Failed to create encryption network ${networkId}: HTTP ${response.status} ${await response.text()}`
@@ -4668,11 +5290,19 @@ var _TinyCloudNode = class _TinyCloudNode {
4668
5290
  const created = await response.json();
4669
5291
  return created.descriptor;
4670
5292
  }
4671
- async ensureEncryptionNetwork(nameOrNetworkId = DEFAULT_ENCRYPTION_NETWORK_NAME) {
5293
+ /**
5294
+ * Ensure an encryption network exists, creating it if necessary.
5295
+ *
5296
+ * @param nameOrNetworkId - Network name or full `urn:tinycloud:encryption:` id.
5297
+ * @param options - See {@link EnsureEncryptionNetworkOptions}.
5298
+ */
5299
+ async ensureEncryptionNetwork(nameOrNetworkId = DEFAULT_ENCRYPTION_NETWORK_NAME, options = {}) {
4672
5300
  const networkId = nameOrNetworkId.startsWith("urn:tinycloud:encryption:") ? nameOrNetworkId : this.getDefaultEncryptionNetworkId(nameOrNetworkId);
4673
- const existing = await this.getEncryptionNetwork(networkId);
4674
- if (existing) {
4675
- return existing;
5301
+ if (!options.assumeMissing) {
5302
+ const existing = await this.getEncryptionNetwork(networkId);
5303
+ if (existing) {
5304
+ return existing;
5305
+ }
4676
5306
  }
4677
5307
  const parsed = (0, import_sdk_core8.parseNetworkId)(networkId);
4678
5308
  if (!didPrincipalMatches2(parsed.ownerDid, this.did)) {
@@ -6451,35 +7081,44 @@ var _TinyCloudNode = class _TinyCloudNode {
6451
7081
  if (parentDelegation.disableSubDelegation) {
6452
7082
  throw new Error("Parent delegation does not allow sub-delegation");
6453
7083
  }
6454
- if (!params.path.startsWith(parentDelegation.path)) {
7084
+ const parentResources = parentDelegation.resources ?? [{
7085
+ service: parentDelegation.actions[0]?.split("/", 1)[0]?.replace(/^tinycloud\./, "") ?? "kv",
7086
+ space: parentDelegation.spaceId,
7087
+ path: parentDelegation.path,
7088
+ actions: parentDelegation.actions
7089
+ }];
7090
+ const requestedResources = params.resources ?? [{
7091
+ service: params.actions[0]?.split("/", 1)[0]?.replace(/^tinycloud\./, "") ?? "kv",
7092
+ space: parentDelegation.spaceId,
7093
+ path: params.path,
7094
+ actions: params.actions
7095
+ }];
7096
+ const resourceIsContained = (parent, child) => {
7097
+ const parentEncryption = parent.service === "encryption" || parent.service === "tinycloud.encryption";
7098
+ const childEncryption = child.service === "encryption" || child.service === "tinycloud.encryption";
7099
+ if (parentEncryption || childEncryption) return parentEncryption && childEncryption && parent.path === child.path && child.actions.every((action) => parent.actions.includes(action));
7100
+ const pathContained = parent.path === child.path || parent.path.endsWith("/") && child.path.startsWith(parent.path) || child.path.startsWith(`${parent.path}/`);
7101
+ return pathContained && child.actions.every((action) => parent.actions.includes(action));
7102
+ };
7103
+ if (requestedResources.length === 0 || requestedResources.some((child) => !parentResources.some((parent) => resourceIsContained(parent, child)))) {
6455
7104
  throw new Error(
6456
- `Sub-delegation path "${params.path}" must be within parent path "${parentDelegation.path}"`
7105
+ "Sub-delegation resources exceed the received delegation"
6457
7106
  );
6458
7107
  }
6459
- const parentActions = new Set(parentDelegation.actions);
6460
- for (const action of params.actions) {
6461
- if (!parentActions.has(action)) {
6462
- throw new Error(
6463
- `Sub-delegation action "${action}" is not in parent's actions: ${parentDelegation.actions.join(", ")}`
6464
- );
6465
- }
6466
- }
6467
7108
  const now = /* @__PURE__ */ new Date();
6468
7109
  const expiryMs = params.expiryMs ?? 60 * 60 * 1e3;
6469
7110
  const requestedExpiry = new Date(now.getTime() + expiryMs);
6470
7111
  const actualExpiry = requestedExpiry > parentDelegation.expiry ? parentDelegation.expiry : requestedExpiry;
6471
7112
  const abilities = {};
6472
- const kvActions = params.actions.filter((a) => a.startsWith("tinycloud.kv/"));
6473
- const sqlActions = params.actions.filter((a) => a.startsWith("tinycloud.sql/"));
6474
- const duckdbActions = params.actions.filter((a) => a.startsWith("tinycloud.duckdb/"));
6475
- if (kvActions.length > 0) {
6476
- abilities.kv = { [params.path]: kvActions };
6477
- }
6478
- if (sqlActions.length > 0) {
6479
- abilities.sql = { [params.path]: sqlActions };
6480
- }
6481
- if (duckdbActions.length > 0) {
6482
- abilities.duckdb = { [params.path]: duckdbActions };
7113
+ const rawAbilities = {};
7114
+ for (const resource of requestedResources) {
7115
+ const service = resource.service.replace(/^tinycloud\./, "");
7116
+ if (service === "encryption") {
7117
+ rawAbilities[resource.path] = [...resource.actions];
7118
+ continue;
7119
+ }
7120
+ abilities[service] ?? (abilities[service] = {});
7121
+ abilities[service][resource.path] = [...resource.actions];
6483
7122
  }
6484
7123
  const targetHost = parentDelegation.host ?? this.config.host;
6485
7124
  const prepared = this.wasmBindings.prepareSession({
@@ -6491,7 +7130,8 @@ var _TinyCloudNode = class _TinyCloudNode {
6491
7130
  expirationTime: actualExpiry.toISOString(),
6492
7131
  spaceId: parentDelegation.spaceId,
6493
7132
  delegateUri: params.delegateDID,
6494
- parents: [parentDelegation.cid]
7133
+ parents: [parentDelegation.cid],
7134
+ ...Object.keys(rawAbilities).length > 0 ? { rawAbilities } : {}
6495
7135
  });
6496
7136
  const signature = await this.signer.signMessage(prepared.siwe);
6497
7137
  const subDelegationSession = this.wasmBindings.completeSessionSetup({
@@ -6509,14 +7149,20 @@ var _TinyCloudNode = class _TinyCloudNode {
6509
7149
  cid: subDelegationSession.delegationCid,
6510
7150
  delegationHeader: subDelegationSession.delegationHeader,
6511
7151
  spaceId: parentDelegation.spaceId,
6512
- path: params.path,
6513
- actions: params.actions,
7152
+ path: requestedResources[0].path,
7153
+ actions: requestedResources[0].actions,
6514
7154
  disableSubDelegation: params.disableSubDelegation ?? false,
6515
7155
  expiry: actualExpiry,
6516
7156
  delegateDID: params.delegateDID,
6517
7157
  ownerAddress: parentDelegation.ownerAddress,
6518
7158
  chainId: parentDelegation.chainId,
6519
- host: targetHost
7159
+ host: targetHost,
7160
+ resources: requestedResources.map((resource) => ({
7161
+ service: resource.service.replace(/^tinycloud\./, ""),
7162
+ space: resource.service.replace(/^tinycloud\./, "") === "encryption" ? "encryption" : resource.space ?? parentDelegation.spaceId,
7163
+ path: resource.path,
7164
+ actions: [...resource.actions]
7165
+ }))
6520
7166
  };
6521
7167
  }
6522
7168
  };