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

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);
@@ -551,6 +574,15 @@ var NodeUserAuthorization = class {
551
574
  get nodeFeatures() {
552
575
  return this._nodeFeatures;
553
576
  }
577
+ /**
578
+ * The node DID reported by `GET /info` for `host`, if the sign-in (or
579
+ * restore) that populated it targeted that same host. Returns `undefined`
580
+ * for any other host, and for nodes whose `/info` omits `nodeId`, so callers
581
+ * fall back to fetching it themselves rather than using a foreign node's DID.
582
+ */
583
+ nodeIdForHost(host) {
584
+ return this._nodeInfoIdentity?.host === host ? this._nodeInfoIdentity.nodeId : void 0;
585
+ }
554
586
  get lastActivationSkippedSpaceIds() {
555
587
  return [...this._lastActivationSkippedSpaceIds];
556
588
  }
@@ -1020,11 +1052,13 @@ var NodeUserAuthorization = class {
1020
1052
  this._tinyCloudSession = tinyCloudSession;
1021
1053
  this._address = address;
1022
1054
  this._chainId = chainId;
1055
+ const infoHost = this.primaryTinyCloudHost;
1023
1056
  const nodeInfo = await (0, import_sdk_core3.checkNodeInfo)(
1024
- this.primaryTinyCloudHost,
1057
+ infoHost,
1025
1058
  this.wasm.protocolVersion()
1026
1059
  );
1027
1060
  this._nodeFeatures = nodeInfo.features;
1061
+ this._nodeInfoIdentity = nodeInfo.nodeId ? { host: infoHost, nodeId: nodeInfo.nodeId } : void 0;
1028
1062
  for (const ext of this.extensions) {
1029
1063
  if (ext.afterSignIn) {
1030
1064
  await ext.afterSignIn(clientSession);
@@ -1190,11 +1224,13 @@ var NodeUserAuthorization = class {
1190
1224
  this._tinyCloudSession = tinyCloudSession;
1191
1225
  this._address = address;
1192
1226
  this._chainId = chainId;
1227
+ const infoHost = this.primaryTinyCloudHost;
1193
1228
  const nodeInfo = await (0, import_sdk_core3.checkNodeInfo)(
1194
- this.primaryTinyCloudHost,
1229
+ infoHost,
1195
1230
  this.wasm.protocolVersion()
1196
1231
  );
1197
1232
  this._nodeFeatures = nodeInfo.features;
1233
+ this._nodeInfoIdentity = nodeInfo.nodeId ? { host: infoHost, nodeId: nodeInfo.nodeId } : void 0;
1198
1234
  for (const ext of this.extensions) {
1199
1235
  if (ext.afterSignIn) {
1200
1236
  await ext.afterSignIn(clientSession);
@@ -1203,6 +1239,398 @@ var NodeUserAuthorization = class {
1203
1239
  await this.ensureSpaceExists();
1204
1240
  return clientSession;
1205
1241
  }
1242
+ /**
1243
+ * End-to-end sign-in via OpenKey `authorizeTinyCloud()` (Sol CRITICAL-4).
1244
+ *
1245
+ * This is the production entry point that wires
1246
+ * {@link prepareSessionForSigning} → OpenKey `authorizeTinyCloud()` →
1247
+ * {@link signInWithOpenKeyResult} into one call. Consumers pass in a
1248
+ * caller-provided `authorizeFn` (typically a thin wrapper around
1249
+ * `openkey.authorizeTinyCloud()`) so this method has no direct
1250
+ * dependency on the OpenKey SDK — it works with any transport that
1251
+ * conforms to the versioned protocol.
1252
+ *
1253
+ * The `authorizeFn` MUST:
1254
+ * 1. Send the prepared SIWE bytes to OpenKey.
1255
+ * 2. Return the exact `TinyCloudAuthorizationResultV1` shape (with
1256
+ * `signedMessage` — the bytes the signature actually verifies
1257
+ * against — plus `selectedActionKeys` and `permissions`).
1258
+ *
1259
+ * NodeUserAuthorization then completes the session with `signedMessage`
1260
+ * (never the prepared bytes), verifying the signature, the immutable
1261
+ * SIWE fields, and every subset invariant enforced by
1262
+ * {@link signInWithOpenKeyResult}. This preserves the exact-byte
1263
+ * guarantee: if the user did NOT narrow, the returned `signedMessage`
1264
+ * equals the prepared SIWE byte-for-byte; if the user narrowed, only
1265
+ * the ReCap block differs and every immutable header field survives.
1266
+ *
1267
+ * Legacy exact-byte {@link signIn} callers are unaffected — this method
1268
+ * is opt-in.
1269
+ *
1270
+ * @param authorizeFn Callback that invokes the versioned OpenKey
1271
+ * `authorizeTinyCloud()` protocol and returns the result. Receives
1272
+ * the prepared SIWE bytes plus a `host` hint the widget should bind
1273
+ * into its /authorize-sign-prepare context.
1274
+ * @param options Optional overrides for the OpenKey request.
1275
+ */
1276
+ async signInWithOpenKey(authorizeFn, options) {
1277
+ if (options?.host !== void 0) {
1278
+ const overrideHost = options.host.trim();
1279
+ if (!overrideHost) {
1280
+ throw new Error("OpenKey authorization host must not be empty");
1281
+ }
1282
+ this.tinycloudHosts = [overrideHost];
1283
+ }
1284
+ const signingAddress = (0, import_sdk_core3.canonicalizeAddress)(await this.signer.getAddress());
1285
+ const signingChainId = await this.signer.getChainId();
1286
+ await this.resolveTinyCloudHostsForSignIn(signingAddress, signingChainId);
1287
+ const { prepared, keyId, jwk } = await this.prepareSessionForSigning();
1288
+ const presentation = this.buildPresentationEnvelope(options?.reason);
1289
+ const hostHint = this.primaryTinyCloudHost;
1290
+ const result = await authorizeFn({
1291
+ protocolVersion: 1,
1292
+ siwe: prepared.siwe,
1293
+ jwk,
1294
+ host: hostHint,
1295
+ keyId: options?.openkeyKeyId,
1296
+ presentation
1297
+ });
1298
+ return this.signInWithOpenKeyResult(result, prepared, keyId, jwk);
1299
+ }
1300
+ /**
1301
+ * Build a `CapabilityPresentationEnvelopeV1` from `this._manifest`. The
1302
+ * envelope is display-only — the OpenKey widget renders it under an
1303
+ * honest trust label, and the server /authorize-sign-prepare route
1304
+ * decides whether to upgrade the label to `origin-bound` by fetching
1305
+ * the well-known manifest and comparing its digest to
1306
+ * `manifestDigest`.
1307
+ *
1308
+ * Returns `undefined` when no manifest is set — the widget renders as
1309
+ * "no manifest supplied" (unsigned).
1310
+ *
1311
+ * The digest is a deterministic SHA-256 over canonical JSON of the
1312
+ * first manifest payload; OpenKey parses the fetched well-known manifest
1313
+ * and applies the same canonicalization before comparing digests.
1314
+ */
1315
+ buildPresentationEnvelope(reason) {
1316
+ const manifests = Array.isArray(this._manifest) ? this._manifest : this._manifest ? [this._manifest] : [];
1317
+ if (manifests.length === 0 && !reason) {
1318
+ return void 0;
1319
+ }
1320
+ const primary = manifests[0];
1321
+ let displayName;
1322
+ let manifestId;
1323
+ let manifestDigest;
1324
+ const envelopeManifests = [];
1325
+ for (const m of manifests) {
1326
+ envelopeManifests.push({
1327
+ name: m.name,
1328
+ appId: m.app_id,
1329
+ // Deep-copy to prevent later mutations from affecting the digest.
1330
+ payload: JSON.parse(JSON.stringify(m))
1331
+ });
1332
+ }
1333
+ if (primary) {
1334
+ displayName = primary.name;
1335
+ manifestId = primary.app_id;
1336
+ manifestDigest = canonicalOpenKeyManifestSha256Hex(primary);
1337
+ }
1338
+ return {
1339
+ protocolVersion: 1,
1340
+ displayName,
1341
+ reason: reason && reason.length > 0 ? reason : void 0,
1342
+ manifestId,
1343
+ manifestDigest,
1344
+ manifests: envelopeManifests.length > 0 ? envelopeManifests : void 0
1345
+ };
1346
+ }
1347
+ /**
1348
+ * Complete sign-in with a versioned OpenKey authorization result.
1349
+ *
1350
+ * Where `signInWithPreparedSession` completes with a signature over the
1351
+ * ORIGINAL prepared SIWE, this method completes with the SIWE bytes the
1352
+ * OpenKey widget actually presented and signed (`signedMessage`). It
1353
+ * exists because the versioned authorizeTinyCloud protocol lets the user
1354
+ * narrow capabilities; the returned bytes may differ from the original
1355
+ * request and are the only ones the signature actually verifies against.
1356
+ *
1357
+ * The `result` parameter is validated at the trust boundary via
1358
+ * `validateAuthorizationResultV1` (in sdk-core). Callers should validate
1359
+ * BEFORE calling this method so unknown/missing fields are rejected
1360
+ * before any session state is created.
1361
+ *
1362
+ * Validation performed here (defence-in-depth: even a "valid" v1 payload
1363
+ * must clear these checks before any session state is created):
1364
+ * 1. `result.address` matches the local signer's address.
1365
+ * 2. `result.signature` recovers to `result.address` when verified against
1366
+ * `result.signedMessage`.
1367
+ * 3. Every immutable SIWE header field (domain, address, URI, version,
1368
+ * chainId, nonce, issuedAt) in the original prepared SIWE is preserved
1369
+ * byte-for-byte in `result.signedMessage`. `statement` is NOT in this
1370
+ * list because the WASM renders it from the ReCap contents; it MUST
1371
+ * change when the ReCap is narrowed.
1372
+ * 4. Every capability in `result.signedMessage`'s ReCap block was present
1373
+ * in the original prepared SIWE — narrowing OK, broadening rejected.
1374
+ * 5. Every `result.selectedActionKeys` entry corresponds to a
1375
+ * (resource, action) pair in the returned SIWE.
1376
+ */
1377
+ async signInWithOpenKeyResult(result, prepared, keyId, jwk) {
1378
+ if (result.protocolVersion !== 1) {
1379
+ throw new Error(`Unsupported OpenKey protocol version ${result.protocolVersion}`);
1380
+ }
1381
+ if (!result.signedMessage) {
1382
+ throw new Error("OpenKey result must include signedMessage");
1383
+ }
1384
+ if (!prepared.siwe) {
1385
+ throw new Error(
1386
+ "signInWithOpenKeyResult requires prepared.siwe (the SDK-generated SIWE the sign-in was proposed with)"
1387
+ );
1388
+ }
1389
+ const expectedAddress = (0, import_sdk_core3.canonicalizeAddress)(await this.signer.getAddress());
1390
+ if ((0, import_sdk_core3.canonicalizeAddress)(result.address) !== expectedAddress) {
1391
+ throw new Error(
1392
+ `OpenKey returned signature from ${result.address} but expected ${expectedAddress}`
1393
+ );
1394
+ }
1395
+ let parsedSignedSiwe;
1396
+ try {
1397
+ parsedSignedSiwe = new import_siwe.SiweMessage(result.signedMessage);
1398
+ } catch (err) {
1399
+ throw new Error(
1400
+ `OpenKey signedMessage is not a parseable SIWE: ${err.message}`
1401
+ );
1402
+ }
1403
+ const verifyResult = await parsedSignedSiwe.verify(
1404
+ { signature: result.signature },
1405
+ { suppressExceptions: true }
1406
+ );
1407
+ if (!verifyResult.success) {
1408
+ const errData = verifyResult.error;
1409
+ const type = errData && typeof errData === "object" && "type" in errData ? errData.type : void 0;
1410
+ const details = errData && typeof errData === "object" && "expected" in errData ? ` (expected=${errData.expected}, received=${errData.received})` : "";
1411
+ throw new Error(
1412
+ `OpenKey signature verification failed${type ? `: ${type}` : ""}${details}`
1413
+ );
1414
+ }
1415
+ if ((0, import_sdk_core3.canonicalizeAddress)(parsedSignedSiwe.address) !== expectedAddress) {
1416
+ throw new Error(
1417
+ `OpenKey signedMessage.address (${parsedSignedSiwe.address}) does not match local signer address (${expectedAddress})`
1418
+ );
1419
+ }
1420
+ const originalHasRecap = /(?:^|\n)-\s+urn:recap:/i.test(prepared.siwe);
1421
+ const originalFields = (0, import_sdk_core3.extractImmutableSiweFields)(prepared.siwe);
1422
+ const signedFields = (0, import_sdk_core3.extractImmutableSiweFields)(result.signedMessage);
1423
+ const changedFields = (0, import_sdk_core3.diffImmutableSiweFields)(originalFields, signedFields, {
1424
+ originalHasRecap
1425
+ });
1426
+ if (changedFields.length > 0) {
1427
+ throw new Error(
1428
+ `OpenKey signedMessage altered immutable SIWE fields: ${changedFields.join(", ")}`
1429
+ );
1430
+ }
1431
+ let originalCaps;
1432
+ let signedCaps;
1433
+ try {
1434
+ originalCaps = (0, import_sdk_core3.extractRecapAttenuations)(prepared.siwe);
1435
+ } catch (err) {
1436
+ throw new Error(
1437
+ `Failed to decode prepared SIWE recap: ${err.message}`
1438
+ );
1439
+ }
1440
+ try {
1441
+ signedCaps = (0, import_sdk_core3.extractRecapAttenuations)(result.signedMessage);
1442
+ } catch (err) {
1443
+ throw new Error(
1444
+ `Failed to decode OpenKey signedMessage recap: ${err.message}`
1445
+ );
1446
+ }
1447
+ const unauthorized = (0, import_sdk_core3.unauthorizedRecapCapabilities)(signedCaps, originalCaps);
1448
+ if (unauthorized.length > 0) {
1449
+ const previewLimit = 5;
1450
+ const preview = unauthorized.slice(0, previewLimit).map((u) => `${u.resource}::${u.action}`).join(", ");
1451
+ const overflow = unauthorized.length > previewLimit ? ` (and ${unauthorized.length - previewLimit} more)` : "";
1452
+ throw new Error(
1453
+ `OpenKey signedMessage broadened authorization beyond the original request: ${preview}${overflow}`
1454
+ );
1455
+ }
1456
+ if (!result.signedMessage.includes(prepared.spaceId)) {
1457
+ throw new Error(
1458
+ `OpenKey signedMessage does not reference the expected spaceId ${prepared.spaceId}`
1459
+ );
1460
+ }
1461
+ const grantedPairs = /* @__PURE__ */ new Set();
1462
+ const grantedFourPartIndex = /* @__PURE__ */ new Map();
1463
+ for (const [resource, actions] of Object.entries(signedCaps)) {
1464
+ const { space, path } = (0, import_sdk_core3.parseCanonicalRecapResource)(resource);
1465
+ for (const action of Object.keys(actions)) {
1466
+ grantedPairs.add(`${resource}\0${action}`);
1467
+ const slashIdx = action.indexOf("/");
1468
+ const service = slashIdx > 0 ? action.slice(0, slashIdx) : "";
1469
+ if (service) {
1470
+ grantedFourPartIndex.set(
1471
+ `${service}\0${space}\0${path}\0${action}`,
1472
+ `${resource}\0${action}`
1473
+ );
1474
+ }
1475
+ }
1476
+ }
1477
+ const isStructurallyRequired = (action) => action === "tinycloud.capabilities/read" || action === "capabilities/read";
1478
+ const nonRequiredGrantedPairs = /* @__PURE__ */ new Set();
1479
+ for (const pair of grantedPairs) {
1480
+ const idx = pair.indexOf("\0");
1481
+ const action = idx >= 0 ? pair.slice(idx + 1) : pair;
1482
+ if (!isStructurallyRequired(action)) {
1483
+ nonRequiredGrantedPairs.add(pair);
1484
+ }
1485
+ }
1486
+ const allSignedGrantedPairs = new Set(grantedPairs);
1487
+ const seenRawKeys = /* @__PURE__ */ new Set();
1488
+ for (const rawKey of result.selectedActionKeys) {
1489
+ if (seenRawKeys.has(rawKey)) {
1490
+ throw new Error(
1491
+ `OpenKey selectedActionKeys contains duplicate entry ${rawKey}`
1492
+ );
1493
+ }
1494
+ seenRawKeys.add(rawKey);
1495
+ }
1496
+ const selectedResourceActionPairs = /* @__PURE__ */ new Set();
1497
+ for (const rawKey of result.selectedActionKeys) {
1498
+ const parts = rawKey.split("\0");
1499
+ if (parts.length !== 4) {
1500
+ throw new Error(
1501
+ `OpenKey selectedActionKeys entry is malformed (expected canonical 4-part service\\0space\\0path\\0ability): ${rawKey}`
1502
+ );
1503
+ }
1504
+ const canonical = grantedFourPartIndex.get(rawKey);
1505
+ if (!canonical) {
1506
+ throw new Error(
1507
+ `OpenKey selectedActionKeys entry ${rawKey} is not covered by any granted capability`
1508
+ );
1509
+ }
1510
+ selectedResourceActionPairs.add(canonical);
1511
+ }
1512
+ if (nonRequiredGrantedPairs.size > 0) {
1513
+ const missingPairs = [];
1514
+ for (const pair of nonRequiredGrantedPairs) {
1515
+ if (!selectedResourceActionPairs.has(pair)) {
1516
+ missingPairs.push(pair);
1517
+ }
1518
+ }
1519
+ if (missingPairs.length > 0) {
1520
+ const previewLimit = 5;
1521
+ const preview = missingPairs.slice(0, previewLimit).map((p) => p.replace("\0", "::")).join(", ");
1522
+ const overflow = missingPairs.length > previewLimit ? ` (and ${missingPairs.length - previewLimit} more)` : "";
1523
+ throw new Error(
1524
+ `OpenKey selectedActionKeys is missing entries for signedMessage capabilities: ${preview}${overflow}`
1525
+ );
1526
+ }
1527
+ }
1528
+ if (allSignedGrantedPairs.size > 0 && result.permissions.length === 0) {
1529
+ throw new Error(
1530
+ "OpenKey permissions is empty but signedMessage carries capabilities \u2014 refusing to accept a session without an authoritative grant list"
1531
+ );
1532
+ }
1533
+ const permissionCanonicalKeys = /* @__PURE__ */ new Set();
1534
+ const permissionPairsCovered = /* @__PURE__ */ new Set();
1535
+ for (const perm of result.permissions) {
1536
+ const { service, space, path, actions } = perm;
1537
+ if (typeof service !== "string" || typeof space !== "string" || typeof path !== "string") {
1538
+ throw new Error(
1539
+ `OpenKey permissions entry has non-string service/space/path (service=${service}, space=${space}, path=${path})`
1540
+ );
1541
+ }
1542
+ if (!Array.isArray(actions) || actions.length === 0) {
1543
+ throw new Error(
1544
+ `OpenKey permissions entry (${service} ${space} ${path}) has empty actions \u2014 a capability-bearing entry must list at least one action`
1545
+ );
1546
+ }
1547
+ const canonicalKey = `${service}\0${space}\0${path}`;
1548
+ if (permissionCanonicalKeys.has(canonicalKey)) {
1549
+ throw new Error(
1550
+ `OpenKey permissions has duplicate entry for (${service} ${space} ${path})`
1551
+ );
1552
+ }
1553
+ permissionCanonicalKeys.add(canonicalKey);
1554
+ let expectedResource;
1555
+ if (space.startsWith("tinycloud:")) {
1556
+ const shortService = service.startsWith("tinycloud.") ? service.slice("tinycloud.".length) : service;
1557
+ expectedResource = path ? `${space}/${shortService}/${path}` : `${space}/${shortService}`;
1558
+ } else {
1559
+ if (path) {
1560
+ throw new Error(
1561
+ `OpenKey permissions entry (${service} ${space} ${path}) carries a path on a non-tinycloud: resource URI, which is not a recognized encoding`
1562
+ );
1563
+ }
1564
+ expectedResource = space;
1565
+ }
1566
+ let matchedResource;
1567
+ if (signedCaps[expectedResource] !== void 0) {
1568
+ matchedResource = expectedResource;
1569
+ } else {
1570
+ throw new Error(
1571
+ `OpenKey permissions entry (${service} ${space} ${path}) claims grants not confirmed in signedMessage capabilities`
1572
+ );
1573
+ }
1574
+ const grantedActionsForResource = new Set(
1575
+ Object.keys(signedCaps[matchedResource])
1576
+ );
1577
+ for (const action of actions) {
1578
+ if (typeof action !== "string" || !action) {
1579
+ throw new Error(
1580
+ `OpenKey permissions entry (${service} ${space} ${path}) has non-string action ${String(action)}`
1581
+ );
1582
+ }
1583
+ if (!grantedActionsForResource.has(action)) {
1584
+ throw new Error(
1585
+ `OpenKey permissions entry (${service} ${space} ${path}) claims action ${action} not present in signedMessage capabilities`
1586
+ );
1587
+ }
1588
+ permissionPairsCovered.add(`${matchedResource}\0${action}`);
1589
+ }
1590
+ }
1591
+ if (allSignedGrantedPairs.size > 0) {
1592
+ const missingFromPerms = [];
1593
+ for (const pair of allSignedGrantedPairs) {
1594
+ if (!permissionPairsCovered.has(pair)) {
1595
+ missingFromPerms.push(pair);
1596
+ }
1597
+ }
1598
+ if (missingFromPerms.length > 0) {
1599
+ const previewLimit = 5;
1600
+ const preview = missingFromPerms.slice(0, previewLimit).map((p) => p.replace("\0", "::")).join(", ");
1601
+ const overflow = missingFromPerms.length > previewLimit ? ` (and ${missingFromPerms.length - previewLimit} more)` : "";
1602
+ throw new Error(
1603
+ `OpenKey permissions is missing entries for signedMessage capabilities: ${preview}${overflow}`
1604
+ );
1605
+ }
1606
+ const extrasInPerms = [];
1607
+ for (const pair of permissionPairsCovered) {
1608
+ if (!allSignedGrantedPairs.has(pair)) {
1609
+ extrasInPerms.push(pair);
1610
+ }
1611
+ }
1612
+ if (extrasInPerms.length > 0) {
1613
+ const previewLimit = 5;
1614
+ const preview = extrasInPerms.slice(0, previewLimit).map((p) => p.replace("\0", "::")).join(", ");
1615
+ const overflow = extrasInPerms.length > previewLimit ? ` (and ${extrasInPerms.length - previewLimit} more)` : "";
1616
+ throw new Error(
1617
+ `OpenKey permissions contains entries not present in the signed authority: ${preview}${overflow}`
1618
+ );
1619
+ }
1620
+ }
1621
+ void nonRequiredGrantedPairs;
1622
+ return this.signInWithPreparedSession(
1623
+ {
1624
+ siwe: result.signedMessage,
1625
+ jwk: prepared.jwk,
1626
+ spaceId: prepared.spaceId,
1627
+ verificationMethod: prepared.verificationMethod
1628
+ },
1629
+ result.signature,
1630
+ keyId,
1631
+ jwk
1632
+ );
1633
+ }
1206
1634
  /**
1207
1635
  * Clear persisted session data.
1208
1636
  */
@@ -2007,7 +2435,9 @@ function base64UrlDecode(value) {
2007
2435
  return new Uint8Array(bytes);
2008
2436
  }
2009
2437
  async function signJwtInputWithJwk(signingInput, jwk) {
2010
- const bytes = new TextEncoder().encode(signingInput);
2438
+ return signBytesWithJwk(new TextEncoder().encode(signingInput), jwk);
2439
+ }
2440
+ async function signBytesWithJwk(bytes, jwk) {
2011
2441
  try {
2012
2442
  const subtle = globalThis.crypto?.subtle;
2013
2443
  if (!subtle) {
@@ -2088,6 +2518,12 @@ var _TinyCloudNode = class _TinyCloudNode {
2088
2518
  this._baseSecrets = /* @__PURE__ */ new Map();
2089
2519
  this._secrets = /* @__PURE__ */ new Map();
2090
2520
  this.runtimePermissionGrants = [];
2521
+ /**
2522
+ * Owned space ids this sign-in has already confirmed are hosted. Consulted by
2523
+ * {@link ensureOwnedSpaceHostedById}; cleared on every {@link signIn} because
2524
+ * hosting is confirmed against the session that was active at the time.
2525
+ */
2526
+ this.confirmedHostedSpaceIds = /* @__PURE__ */ new Set();
2091
2527
  /**
2092
2528
  * True when the last signIn() detected an interactive signer and skipped
2093
2529
  * client-side bootstrap. Apps can read this to know whether bootstrap was
@@ -2098,7 +2534,10 @@ var _TinyCloudNode = class _TinyCloudNode {
2098
2534
  * Outcome of the last signIn()'s account-bootstrap attempt. `skipped` is
2099
2535
  * true when bootstrap did not complete (interactive signer, auto-sign
2100
2536
  * denied, or a bootstrap step failed); `reason` carries the cause so apps
2101
- * can surface a "finish account setup" call-to-action.
2537
+ * can surface a "finish account setup" call-to-action. `warnings` is
2538
+ * present when bootstrap completed, but one or more steps recovered from
2539
+ * an ambiguous failure (e.g. a KV batch write reconciled via per-space
2540
+ * fallback) — clean vs. recovered runs are otherwise byte-identical.
2102
2541
  */
2103
2542
  this._bootstrapStatus = {
2104
2543
  skipped: false
@@ -2484,6 +2923,7 @@ var _TinyCloudNode = class _TinyCloudNode {
2484
2923
  this._spaceService = void 0;
2485
2924
  this._serviceContext = void 0;
2486
2925
  this.runtimePermissionGrants = [];
2926
+ this.confirmedHostedSpaceIds.clear();
2487
2927
  await this.tc.signIn(options);
2488
2928
  this.syncResolvedHostFromAuth();
2489
2929
  const oldGraph = this._serviceGraph;
@@ -2538,7 +2978,13 @@ var _TinyCloudNode = class _TinyCloudNode {
2538
2978
  return false;
2539
2979
  }
2540
2980
  try {
2541
- await this.runAccountBootstrap(steps);
2981
+ const warnings = await this.runAccountBootstrap(steps);
2982
+ if (warnings.length > 0) {
2983
+ this._bootstrapStatus = { skipped: false, warnings };
2984
+ this.notificationHandler.warning(
2985
+ `Account bootstrap completed with warnings: ${warnings.map((w) => `${w.stepId} (${w.kind}: ${w.code})`).join("; ")}`
2986
+ );
2987
+ }
2542
2988
  } catch (err) {
2543
2989
  const reason = err instanceof Error ? err.message : String(err);
2544
2990
  this._bootstrapSkipped = true;
@@ -2587,6 +3033,7 @@ var _TinyCloudNode = class _TinyCloudNode {
2587
3033
  const auth = this.auth;
2588
3034
  const sessions = /* @__PURE__ */ new Map();
2589
3035
  const rawAbilitiesBySpace = /* @__PURE__ */ new Map();
3036
+ const warnings = [];
2590
3037
  const primarySession = auth.tinyCloudSession;
2591
3038
  const defaultSpaceId = this.ownedSpaceId("default");
2592
3039
  const canReusePrimaryBootstrapSession = primarySession?.spaceId === defaultSpaceId && auth.capabilityRequest === import_sdk_core8.BOOTSTRAP_SESSION_REQUESTS.default;
@@ -2648,32 +3095,45 @@ var _TinyCloudNode = class _TinyCloudNode {
2648
3095
  }
2649
3096
  }
2650
3097
  if (step.kind === "seed-spaces") {
2651
- for (const space of step.spaces) {
2652
- const registered = await this.account.spaces.register({
3098
+ const registered = await this.account.spaces.registerBatch(
3099
+ step.spaces.map((space) => ({
2653
3100
  spaceId: space.spaceId,
2654
3101
  name: space.name,
2655
3102
  ownerDid: this.did,
2656
3103
  type: "owned",
2657
3104
  permissions: ["*"],
2658
3105
  status: "active"
3106
+ }))
3107
+ );
3108
+ if (!registered.ok) {
3109
+ throw new Error(
3110
+ `Failed to seed account spaces: ${registered.error.message}`
3111
+ );
3112
+ }
3113
+ const batchError = registered.data.recoveredFromBatchError;
3114
+ if (batchError) {
3115
+ warnings.push({
3116
+ stepId: step.id,
3117
+ kind: "batch-write-reconciled",
3118
+ code: batchError.code,
3119
+ message: batchError.message
2659
3120
  });
2660
- if (!registered.ok) {
2661
- throw new Error(
2662
- `Failed to seed account space ${space.spaceId}: ${registered.error.message}`
2663
- );
2664
- }
2665
3121
  }
2666
3122
  }
2667
3123
  if (step.kind === "seed-applications") {
2668
3124
  const registered = await this.account.applications.register(
2669
- step.manifests.length > 0 ? [...step.manifests] : import_sdk_core8.TINYCLOUD_SECRETS_BOOTSTRAP_MANIFEST
3125
+ step.manifests.length > 0 ? [...step.manifests] : import_sdk_core8.TINYCLOUD_SECRETS_BOOTSTRAP_MANIFEST,
3126
+ // Bootstrap only runs on an account with no registry records, so the
3127
+ // manifest-hash pre-read is definitionally a miss. Skip it; the write
3128
+ // it guards is an INSERT OR REPLACE and is safe to repeat.
3129
+ { assumeUnregistered: true }
2670
3130
  );
2671
3131
  if (!registered.ok) {
2672
3132
  throw new Error(`Failed to seed bootstrap applications: ${registered.error.message}`);
2673
3133
  }
2674
3134
  }
2675
3135
  if (step.kind === "encryption-network-create") {
2676
- await this.ensureEncryptionNetwork(step.networkId);
3136
+ await this.ensureEncryptionNetwork(step.networkId, { assumeMissing: true });
2677
3137
  }
2678
3138
  if (step.kind === "secret-records-schema") {
2679
3139
  const db = this.sqlForSpace(step.spaceId).db(step.database);
@@ -2693,6 +3153,7 @@ var _TinyCloudNode = class _TinyCloudNode {
2693
3153
  }
2694
3154
  }
2695
3155
  }
3156
+ return warnings;
2696
3157
  }
2697
3158
  registerBootstrapRuntimeGrant(session, request, rawAbilities) {
2698
3159
  const operations = [];
@@ -2952,6 +3413,22 @@ var _TinyCloudNode = class _TinyCloudNode {
2952
3413
  await this.ensureEncryptionNetwork(networkId);
2953
3414
  }
2954
3415
  }
3416
+ /**
3417
+ * Ensure one of this user's owned spaces is hosted, at most once per space
3418
+ * per sign-in.
3419
+ *
3420
+ * Account bootstrap funnels six calls through here (via
3421
+ * `AccountService.ensureAccountSpaceHosted`), and each one re-submits
3422
+ * `activateSessionWithHost` with the *primary* session. That session's recap
3423
+ * covers only `default` and `secrets`, so the account space appears in
3424
+ * neither `activated` nor `skipped` and the guard below returns immediately
3425
+ * — the repeat calls cannot change the outcome, they only cost round trips.
3426
+ *
3427
+ * The memo is per sign-in ({@link confirmedHostedSpaceIds} is cleared in
3428
+ * `signIn`) and only records confirmed successes. A wrong skip is
3429
+ * self-revealing: the next write to the space fails immediately and loudly
3430
+ * with `404 Space not found`.
3431
+ */
2955
3432
  async ensureOwnedSpaceHostedById(spaceId) {
2956
3433
  if (!this.auth) {
2957
3434
  throw new Error("Owned space hosting requires wallet mode");
@@ -2960,12 +3437,16 @@ var _TinyCloudNode = class _TinyCloudNode {
2960
3437
  if (!session) {
2961
3438
  throw new Error("Owned space hosting requires an active session");
2962
3439
  }
3440
+ if (this.confirmedHostedSpaceIds.has(spaceId)) {
3441
+ return;
3442
+ }
2963
3443
  const host = this.hosts[0] ?? this.config.host;
2964
3444
  if (!host) {
2965
3445
  throw new Error("Owned space hosting requires a TinyCloud host");
2966
3446
  }
2967
3447
  const activation = await (0, import_sdk_core8.activateSessionWithHost)(host, session.delegationHeader);
2968
3448
  if (activation.success && !activation.skipped?.includes(spaceId)) {
3449
+ this.confirmedHostedSpaceIds.add(spaceId);
2969
3450
  return;
2970
3451
  }
2971
3452
  if (!activation.success && activation.status !== 404) {
@@ -2984,6 +3465,7 @@ var _TinyCloudNode = class _TinyCloudNode {
2984
3465
  `Failed to activate session after creating owned space ${spaceId}: ${retry.error ?? "space was skipped"}`
2985
3466
  );
2986
3467
  }
3468
+ this.confirmedHostedSpaceIds.add(spaceId);
2987
3469
  }
2988
3470
  /**
2989
3471
  * Host one of this user's owned spaces by name (e.g. `"applications"`).
@@ -3299,6 +3781,7 @@ var _TinyCloudNode = class _TinyCloudNode {
3299
3781
  this._delegationManager = stagedGraph.delegationManager;
3300
3782
  this._spaceService = stagedGraph.spaceService;
3301
3783
  this._serviceGraph = stagedGraph.graph;
3784
+ this._activeServiceSession = serviceSession;
3302
3785
  this._baseSecrets = /* @__PURE__ */ new Map();
3303
3786
  this._secrets = /* @__PURE__ */ new Map();
3304
3787
  this._publicKV = void 0;
@@ -3306,6 +3789,7 @@ var _TinyCloudNode = class _TinyCloudNode {
3306
3789
  this.tc = stagedGraph.core;
3307
3790
  this._recapOperationsCache = stagedPrimary.cache;
3308
3791
  this.runtimePermissionGrants = stagedPrimary.grants;
3792
+ this.confirmedHostedSpaceIds.clear();
3309
3793
  if (this.auth) {
3310
3794
  this.auth.installRestoredSession(stagedManager, stagedTcSession, [stagedHost]);
3311
3795
  this._restoredTcSession = void 0;
@@ -3615,6 +4099,28 @@ var _TinyCloudNode = class _TinyCloudNode {
3615
4099
  currentTinyCloudSession() {
3616
4100
  return this.auth?.tinyCloudSession ?? this._restoredTcSession;
3617
4101
  }
4102
+ /**
4103
+ * Mint a multi-capability invocation through the established session key.
4104
+ * Callers receive only the signed headers; session key material remains
4105
+ * owned by the session manager and is never part of the caller contract.
4106
+ */
4107
+ invokeAny(entries, facts) {
4108
+ const session = this.currentTinyCloudSession() ?? this._activeServiceSession;
4109
+ if (session === void 0) throw new Error("Not signed in. Call signIn() first.");
4110
+ return this._serviceGraph.invokeAny(session, entries, facts);
4111
+ }
4112
+ /** Sign protocol bytes with the established in-memory session key. */
4113
+ async signSessionBytes(bytes) {
4114
+ const session = this.currentTinyCloudSession() ?? this._activeServiceSession;
4115
+ if (session === void 0) throw new Error("Not signed in. Call signIn() first.");
4116
+ return signBytesWithJwk(bytes, session.jwk);
4117
+ }
4118
+ /** Bind a session-signed invocation to the canonical service audience. */
4119
+ async bindInvocationAudience(authorization, audience) {
4120
+ const session = this.currentTinyCloudSession() ?? this._activeServiceSession;
4121
+ if (session === void 0) throw new Error("Not signed in. Call signIn() first.");
4122
+ return rewriteInvocationAudience(authorization, audience, session.jwk);
4123
+ }
3618
4124
  /**
3619
4125
  * Connect a wallet to upgrade from session-only mode to wallet mode.
3620
4126
  *
@@ -3796,6 +4302,7 @@ var _TinyCloudNode = class _TinyCloudNode {
3796
4302
  if (this._serviceContext) {
3797
4303
  const spaceScopedContext = this._serviceGraph.track(new import_sdk_core8.ServiceContext({
3798
4304
  invoke: this._serviceContext.invoke,
4305
+ invokeAny: this._serviceContext.invokeAny,
3799
4306
  fetch: this._serviceContext.fetch,
3800
4307
  hosts: this._serviceContext.hosts,
3801
4308
  telemetry: this.config.telemetry
@@ -3888,7 +4395,17 @@ var _TinyCloudNode = class _TinyCloudNode {
3888
4395
  verifyNodeSignature: (nodeId, message, signature) => (0, import_sdk_core8.verifyDidKeyEd25519Signature)(nodeId, message, signature)
3889
4396
  };
3890
4397
  }
4398
+ /**
4399
+ * The node DID for {@link config.host}.
4400
+ *
4401
+ * Sign-in already performs `GET /info` for the protocol-version check, and
4402
+ * that response carries `nodeId`, so reuse it rather than fetching `/info` a
4403
+ * second time. Only a value recorded for this exact host qualifies; older
4404
+ * nodes that omit `nodeId` fall through to the fetch.
4405
+ */
3891
4406
  async fetchNodeId() {
4407
+ const cached = this.auth?.nodeIdForHost(this.config.host);
4408
+ if (cached) return cached;
3892
4409
  const response = await fetch(`${this.config.host}/info`);
3893
4410
  if (!response.ok) {
3894
4411
  throw new Error(`Failed to fetch node info: HTTP ${response.status}`);
@@ -4451,6 +4968,7 @@ var _TinyCloudNode = class _TinyCloudNode {
4451
4968
  shareUrl: input.shareUrl,
4452
4969
  documentName: input.documentName,
4453
4970
  jti: base64UrlEncode(crypto.getRandomValues(new Uint8Array(16))),
4971
+ idempotencyKey: input.idempotencyKey,
4454
4972
  expiresAt: input.expiresAt
4455
4973
  };
4456
4974
  const requestBodyDigest = base64UrlEncode(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode((0, import_sdk_core8.canonicalizeEncryptionJson)(body)))));
@@ -4660,6 +5178,13 @@ var _TinyCloudNode = class _TinyCloudNode {
4660
5178
  body
4661
5179
  )
4662
5180
  });
5181
+ if (response.status === 409) {
5182
+ const existing = await this.getEncryptionNetwork(networkId);
5183
+ if (existing) return existing;
5184
+ throw new Error(
5185
+ `Encryption network ${networkId} already exists but could not be read back`
5186
+ );
5187
+ }
4663
5188
  if (!response.ok) {
4664
5189
  throw new Error(
4665
5190
  `Failed to create encryption network ${networkId}: HTTP ${response.status} ${await response.text()}`
@@ -4668,11 +5193,19 @@ var _TinyCloudNode = class _TinyCloudNode {
4668
5193
  const created = await response.json();
4669
5194
  return created.descriptor;
4670
5195
  }
4671
- async ensureEncryptionNetwork(nameOrNetworkId = DEFAULT_ENCRYPTION_NETWORK_NAME) {
5196
+ /**
5197
+ * Ensure an encryption network exists, creating it if necessary.
5198
+ *
5199
+ * @param nameOrNetworkId - Network name or full `urn:tinycloud:encryption:` id.
5200
+ * @param options - See {@link EnsureEncryptionNetworkOptions}.
5201
+ */
5202
+ async ensureEncryptionNetwork(nameOrNetworkId = DEFAULT_ENCRYPTION_NETWORK_NAME, options = {}) {
4672
5203
  const networkId = nameOrNetworkId.startsWith("urn:tinycloud:encryption:") ? nameOrNetworkId : this.getDefaultEncryptionNetworkId(nameOrNetworkId);
4673
- const existing = await this.getEncryptionNetwork(networkId);
4674
- if (existing) {
4675
- return existing;
5204
+ if (!options.assumeMissing) {
5205
+ const existing = await this.getEncryptionNetwork(networkId);
5206
+ if (existing) {
5207
+ return existing;
5208
+ }
4676
5209
  }
4677
5210
  const parsed = (0, import_sdk_core8.parseNetworkId)(networkId);
4678
5211
  if (!didPrincipalMatches2(parsed.ownerDid, this.did)) {