@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.js CHANGED
@@ -220,6 +220,8 @@ var FileSessionStorage = class {
220
220
  };
221
221
 
222
222
  // src/authorization/NodeUserAuthorization.ts
223
+ import { createHash } from "crypto";
224
+ import { SiweMessage } from "siwe";
223
225
  import {
224
226
  fetchPeerId,
225
227
  submitHostDelegation,
@@ -238,6 +240,11 @@ import {
238
240
  canonicalizeAddress,
239
241
  makePkhSpaceId,
240
242
  pkhDid,
243
+ extractImmutableSiweFields,
244
+ diffImmutableSiweFields,
245
+ extractRecapAttenuations,
246
+ unauthorizedRecapCapabilities,
247
+ parseCanonicalRecapResource,
241
248
  KV,
242
249
  SQL,
243
250
  DUCKDB,
@@ -253,6 +260,27 @@ var defaultSignStrategy = { type: "auto-sign" };
253
260
  // src/authorization/NodeUserAuthorization.ts
254
261
  var DECRYPT_ACTION = ENCRYPTION.DECRYPT;
255
262
  var NETWORK_CREATE_ACTION = ENCRYPTION.NETWORK_CREATE;
263
+ function canonicalizeOpenKeyManifestJson(value) {
264
+ const json = JSON.stringify(value);
265
+ if (json === void 0) {
266
+ throw new TypeError("manifest is not JSON-serializable");
267
+ }
268
+ return canonicalizeJsonValue(JSON.parse(json));
269
+ }
270
+ function canonicalizeJsonValue(value) {
271
+ if (value === null || typeof value !== "object") {
272
+ return JSON.stringify(value);
273
+ }
274
+ if (Array.isArray(value)) {
275
+ return `[${value.map(canonicalizeJsonValue).join(",")}]`;
276
+ }
277
+ return `{${Object.keys(value).sort().map(
278
+ (key) => `${JSON.stringify(key)}:${canonicalizeJsonValue(value[key])}`
279
+ ).join(",")}}`;
280
+ }
281
+ function canonicalOpenKeyManifestSha256Hex(value) {
282
+ return createHash("sha256").update(canonicalizeOpenKeyManifestJson(value)).digest("hex");
283
+ }
256
284
  function didPrincipalMatches(actual, expected) {
257
285
  try {
258
286
  return principalDidEquals(actual, expected);
@@ -473,6 +501,15 @@ var NodeUserAuthorization = class {
473
501
  get nodeFeatures() {
474
502
  return this._nodeFeatures;
475
503
  }
504
+ /**
505
+ * The node DID reported by `GET /info` for `host`, if the sign-in (or
506
+ * restore) that populated it targeted that same host. Returns `undefined`
507
+ * for any other host, and for nodes whose `/info` omits `nodeId`, so callers
508
+ * fall back to fetching it themselves rather than using a foreign node's DID.
509
+ */
510
+ nodeIdForHost(host) {
511
+ return this._nodeInfoIdentity?.host === host ? this._nodeInfoIdentity.nodeId : void 0;
512
+ }
476
513
  get lastActivationSkippedSpaceIds() {
477
514
  return [...this._lastActivationSkippedSpaceIds];
478
515
  }
@@ -942,11 +979,13 @@ var NodeUserAuthorization = class {
942
979
  this._tinyCloudSession = tinyCloudSession;
943
980
  this._address = address;
944
981
  this._chainId = chainId;
982
+ const infoHost = this.primaryTinyCloudHost;
945
983
  const nodeInfo = await checkNodeInfo(
946
- this.primaryTinyCloudHost,
984
+ infoHost,
947
985
  this.wasm.protocolVersion()
948
986
  );
949
987
  this._nodeFeatures = nodeInfo.features;
988
+ this._nodeInfoIdentity = nodeInfo.nodeId ? { host: infoHost, nodeId: nodeInfo.nodeId } : void 0;
950
989
  for (const ext of this.extensions) {
951
990
  if (ext.afterSignIn) {
952
991
  await ext.afterSignIn(clientSession);
@@ -1112,11 +1151,13 @@ var NodeUserAuthorization = class {
1112
1151
  this._tinyCloudSession = tinyCloudSession;
1113
1152
  this._address = address;
1114
1153
  this._chainId = chainId;
1154
+ const infoHost = this.primaryTinyCloudHost;
1115
1155
  const nodeInfo = await checkNodeInfo(
1116
- this.primaryTinyCloudHost,
1156
+ infoHost,
1117
1157
  this.wasm.protocolVersion()
1118
1158
  );
1119
1159
  this._nodeFeatures = nodeInfo.features;
1160
+ this._nodeInfoIdentity = nodeInfo.nodeId ? { host: infoHost, nodeId: nodeInfo.nodeId } : void 0;
1120
1161
  for (const ext of this.extensions) {
1121
1162
  if (ext.afterSignIn) {
1122
1163
  await ext.afterSignIn(clientSession);
@@ -1125,6 +1166,398 @@ var NodeUserAuthorization = class {
1125
1166
  await this.ensureSpaceExists();
1126
1167
  return clientSession;
1127
1168
  }
1169
+ /**
1170
+ * End-to-end sign-in via OpenKey `authorizeTinyCloud()` (Sol CRITICAL-4).
1171
+ *
1172
+ * This is the production entry point that wires
1173
+ * {@link prepareSessionForSigning} → OpenKey `authorizeTinyCloud()` →
1174
+ * {@link signInWithOpenKeyResult} into one call. Consumers pass in a
1175
+ * caller-provided `authorizeFn` (typically a thin wrapper around
1176
+ * `openkey.authorizeTinyCloud()`) so this method has no direct
1177
+ * dependency on the OpenKey SDK — it works with any transport that
1178
+ * conforms to the versioned protocol.
1179
+ *
1180
+ * The `authorizeFn` MUST:
1181
+ * 1. Send the prepared SIWE bytes to OpenKey.
1182
+ * 2. Return the exact `TinyCloudAuthorizationResultV1` shape (with
1183
+ * `signedMessage` — the bytes the signature actually verifies
1184
+ * against — plus `selectedActionKeys` and `permissions`).
1185
+ *
1186
+ * NodeUserAuthorization then completes the session with `signedMessage`
1187
+ * (never the prepared bytes), verifying the signature, the immutable
1188
+ * SIWE fields, and every subset invariant enforced by
1189
+ * {@link signInWithOpenKeyResult}. This preserves the exact-byte
1190
+ * guarantee: if the user did NOT narrow, the returned `signedMessage`
1191
+ * equals the prepared SIWE byte-for-byte; if the user narrowed, only
1192
+ * the ReCap block differs and every immutable header field survives.
1193
+ *
1194
+ * Legacy exact-byte {@link signIn} callers are unaffected — this method
1195
+ * is opt-in.
1196
+ *
1197
+ * @param authorizeFn Callback that invokes the versioned OpenKey
1198
+ * `authorizeTinyCloud()` protocol and returns the result. Receives
1199
+ * the prepared SIWE bytes plus a `host` hint the widget should bind
1200
+ * into its /authorize-sign-prepare context.
1201
+ * @param options Optional overrides for the OpenKey request.
1202
+ */
1203
+ async signInWithOpenKey(authorizeFn, options) {
1204
+ if (options?.host !== void 0) {
1205
+ const overrideHost = options.host.trim();
1206
+ if (!overrideHost) {
1207
+ throw new Error("OpenKey authorization host must not be empty");
1208
+ }
1209
+ this.tinycloudHosts = [overrideHost];
1210
+ }
1211
+ const signingAddress = canonicalizeAddress(await this.signer.getAddress());
1212
+ const signingChainId = await this.signer.getChainId();
1213
+ await this.resolveTinyCloudHostsForSignIn(signingAddress, signingChainId);
1214
+ const { prepared, keyId, jwk } = await this.prepareSessionForSigning();
1215
+ const presentation = this.buildPresentationEnvelope(options?.reason);
1216
+ const hostHint = this.primaryTinyCloudHost;
1217
+ const result = await authorizeFn({
1218
+ protocolVersion: 1,
1219
+ siwe: prepared.siwe,
1220
+ jwk,
1221
+ host: hostHint,
1222
+ keyId: options?.openkeyKeyId,
1223
+ presentation
1224
+ });
1225
+ return this.signInWithOpenKeyResult(result, prepared, keyId, jwk);
1226
+ }
1227
+ /**
1228
+ * Build a `CapabilityPresentationEnvelopeV1` from `this._manifest`. The
1229
+ * envelope is display-only — the OpenKey widget renders it under an
1230
+ * honest trust label, and the server /authorize-sign-prepare route
1231
+ * decides whether to upgrade the label to `origin-bound` by fetching
1232
+ * the well-known manifest and comparing its digest to
1233
+ * `manifestDigest`.
1234
+ *
1235
+ * Returns `undefined` when no manifest is set — the widget renders as
1236
+ * "no manifest supplied" (unsigned).
1237
+ *
1238
+ * The digest is a deterministic SHA-256 over canonical JSON of the
1239
+ * first manifest payload; OpenKey parses the fetched well-known manifest
1240
+ * and applies the same canonicalization before comparing digests.
1241
+ */
1242
+ buildPresentationEnvelope(reason) {
1243
+ const manifests = Array.isArray(this._manifest) ? this._manifest : this._manifest ? [this._manifest] : [];
1244
+ if (manifests.length === 0 && !reason) {
1245
+ return void 0;
1246
+ }
1247
+ const primary = manifests[0];
1248
+ let displayName;
1249
+ let manifestId;
1250
+ let manifestDigest;
1251
+ const envelopeManifests = [];
1252
+ for (const m of manifests) {
1253
+ envelopeManifests.push({
1254
+ name: m.name,
1255
+ appId: m.app_id,
1256
+ // Deep-copy to prevent later mutations from affecting the digest.
1257
+ payload: JSON.parse(JSON.stringify(m))
1258
+ });
1259
+ }
1260
+ if (primary) {
1261
+ displayName = primary.name;
1262
+ manifestId = primary.app_id;
1263
+ manifestDigest = canonicalOpenKeyManifestSha256Hex(primary);
1264
+ }
1265
+ return {
1266
+ protocolVersion: 1,
1267
+ displayName,
1268
+ reason: reason && reason.length > 0 ? reason : void 0,
1269
+ manifestId,
1270
+ manifestDigest,
1271
+ manifests: envelopeManifests.length > 0 ? envelopeManifests : void 0
1272
+ };
1273
+ }
1274
+ /**
1275
+ * Complete sign-in with a versioned OpenKey authorization result.
1276
+ *
1277
+ * Where `signInWithPreparedSession` completes with a signature over the
1278
+ * ORIGINAL prepared SIWE, this method completes with the SIWE bytes the
1279
+ * OpenKey widget actually presented and signed (`signedMessage`). It
1280
+ * exists because the versioned authorizeTinyCloud protocol lets the user
1281
+ * narrow capabilities; the returned bytes may differ from the original
1282
+ * request and are the only ones the signature actually verifies against.
1283
+ *
1284
+ * The `result` parameter is validated at the trust boundary via
1285
+ * `validateAuthorizationResultV1` (in sdk-core). Callers should validate
1286
+ * BEFORE calling this method so unknown/missing fields are rejected
1287
+ * before any session state is created.
1288
+ *
1289
+ * Validation performed here (defence-in-depth: even a "valid" v1 payload
1290
+ * must clear these checks before any session state is created):
1291
+ * 1. `result.address` matches the local signer's address.
1292
+ * 2. `result.signature` recovers to `result.address` when verified against
1293
+ * `result.signedMessage`.
1294
+ * 3. Every immutable SIWE header field (domain, address, URI, version,
1295
+ * chainId, nonce, issuedAt) in the original prepared SIWE is preserved
1296
+ * byte-for-byte in `result.signedMessage`. `statement` is NOT in this
1297
+ * list because the WASM renders it from the ReCap contents; it MUST
1298
+ * change when the ReCap is narrowed.
1299
+ * 4. Every capability in `result.signedMessage`'s ReCap block was present
1300
+ * in the original prepared SIWE — narrowing OK, broadening rejected.
1301
+ * 5. Every `result.selectedActionKeys` entry corresponds to a
1302
+ * (resource, action) pair in the returned SIWE.
1303
+ */
1304
+ async signInWithOpenKeyResult(result, prepared, keyId, jwk) {
1305
+ if (result.protocolVersion !== 1) {
1306
+ throw new Error(`Unsupported OpenKey protocol version ${result.protocolVersion}`);
1307
+ }
1308
+ if (!result.signedMessage) {
1309
+ throw new Error("OpenKey result must include signedMessage");
1310
+ }
1311
+ if (!prepared.siwe) {
1312
+ throw new Error(
1313
+ "signInWithOpenKeyResult requires prepared.siwe (the SDK-generated SIWE the sign-in was proposed with)"
1314
+ );
1315
+ }
1316
+ const expectedAddress = canonicalizeAddress(await this.signer.getAddress());
1317
+ if (canonicalizeAddress(result.address) !== expectedAddress) {
1318
+ throw new Error(
1319
+ `OpenKey returned signature from ${result.address} but expected ${expectedAddress}`
1320
+ );
1321
+ }
1322
+ let parsedSignedSiwe;
1323
+ try {
1324
+ parsedSignedSiwe = new SiweMessage(result.signedMessage);
1325
+ } catch (err) {
1326
+ throw new Error(
1327
+ `OpenKey signedMessage is not a parseable SIWE: ${err.message}`
1328
+ );
1329
+ }
1330
+ const verifyResult = await parsedSignedSiwe.verify(
1331
+ { signature: result.signature },
1332
+ { suppressExceptions: true }
1333
+ );
1334
+ if (!verifyResult.success) {
1335
+ const errData = verifyResult.error;
1336
+ const type = errData && typeof errData === "object" && "type" in errData ? errData.type : void 0;
1337
+ const details = errData && typeof errData === "object" && "expected" in errData ? ` (expected=${errData.expected}, received=${errData.received})` : "";
1338
+ throw new Error(
1339
+ `OpenKey signature verification failed${type ? `: ${type}` : ""}${details}`
1340
+ );
1341
+ }
1342
+ if (canonicalizeAddress(parsedSignedSiwe.address) !== expectedAddress) {
1343
+ throw new Error(
1344
+ `OpenKey signedMessage.address (${parsedSignedSiwe.address}) does not match local signer address (${expectedAddress})`
1345
+ );
1346
+ }
1347
+ const originalHasRecap = /(?:^|\n)-\s+urn:recap:/i.test(prepared.siwe);
1348
+ const originalFields = extractImmutableSiweFields(prepared.siwe);
1349
+ const signedFields = extractImmutableSiweFields(result.signedMessage);
1350
+ const changedFields = diffImmutableSiweFields(originalFields, signedFields, {
1351
+ originalHasRecap
1352
+ });
1353
+ if (changedFields.length > 0) {
1354
+ throw new Error(
1355
+ `OpenKey signedMessage altered immutable SIWE fields: ${changedFields.join(", ")}`
1356
+ );
1357
+ }
1358
+ let originalCaps;
1359
+ let signedCaps;
1360
+ try {
1361
+ originalCaps = extractRecapAttenuations(prepared.siwe);
1362
+ } catch (err) {
1363
+ throw new Error(
1364
+ `Failed to decode prepared SIWE recap: ${err.message}`
1365
+ );
1366
+ }
1367
+ try {
1368
+ signedCaps = extractRecapAttenuations(result.signedMessage);
1369
+ } catch (err) {
1370
+ throw new Error(
1371
+ `Failed to decode OpenKey signedMessage recap: ${err.message}`
1372
+ );
1373
+ }
1374
+ const unauthorized = unauthorizedRecapCapabilities(signedCaps, originalCaps);
1375
+ if (unauthorized.length > 0) {
1376
+ const previewLimit = 5;
1377
+ const preview = unauthorized.slice(0, previewLimit).map((u) => `${u.resource}::${u.action}`).join(", ");
1378
+ const overflow = unauthorized.length > previewLimit ? ` (and ${unauthorized.length - previewLimit} more)` : "";
1379
+ throw new Error(
1380
+ `OpenKey signedMessage broadened authorization beyond the original request: ${preview}${overflow}`
1381
+ );
1382
+ }
1383
+ if (!result.signedMessage.includes(prepared.spaceId)) {
1384
+ throw new Error(
1385
+ `OpenKey signedMessage does not reference the expected spaceId ${prepared.spaceId}`
1386
+ );
1387
+ }
1388
+ const grantedPairs = /* @__PURE__ */ new Set();
1389
+ const grantedFourPartIndex = /* @__PURE__ */ new Map();
1390
+ for (const [resource, actions] of Object.entries(signedCaps)) {
1391
+ const { space, path } = parseCanonicalRecapResource(resource);
1392
+ for (const action of Object.keys(actions)) {
1393
+ grantedPairs.add(`${resource}\0${action}`);
1394
+ const slashIdx = action.indexOf("/");
1395
+ const service = slashIdx > 0 ? action.slice(0, slashIdx) : "";
1396
+ if (service) {
1397
+ grantedFourPartIndex.set(
1398
+ `${service}\0${space}\0${path}\0${action}`,
1399
+ `${resource}\0${action}`
1400
+ );
1401
+ }
1402
+ }
1403
+ }
1404
+ const isStructurallyRequired = (action) => action === "tinycloud.capabilities/read" || action === "capabilities/read";
1405
+ const nonRequiredGrantedPairs = /* @__PURE__ */ new Set();
1406
+ for (const pair of grantedPairs) {
1407
+ const idx = pair.indexOf("\0");
1408
+ const action = idx >= 0 ? pair.slice(idx + 1) : pair;
1409
+ if (!isStructurallyRequired(action)) {
1410
+ nonRequiredGrantedPairs.add(pair);
1411
+ }
1412
+ }
1413
+ const allSignedGrantedPairs = new Set(grantedPairs);
1414
+ const seenRawKeys = /* @__PURE__ */ new Set();
1415
+ for (const rawKey of result.selectedActionKeys) {
1416
+ if (seenRawKeys.has(rawKey)) {
1417
+ throw new Error(
1418
+ `OpenKey selectedActionKeys contains duplicate entry ${rawKey}`
1419
+ );
1420
+ }
1421
+ seenRawKeys.add(rawKey);
1422
+ }
1423
+ const selectedResourceActionPairs = /* @__PURE__ */ new Set();
1424
+ for (const rawKey of result.selectedActionKeys) {
1425
+ const parts = rawKey.split("\0");
1426
+ if (parts.length !== 4) {
1427
+ throw new Error(
1428
+ `OpenKey selectedActionKeys entry is malformed (expected canonical 4-part service\\0space\\0path\\0ability): ${rawKey}`
1429
+ );
1430
+ }
1431
+ const canonical = grantedFourPartIndex.get(rawKey);
1432
+ if (!canonical) {
1433
+ throw new Error(
1434
+ `OpenKey selectedActionKeys entry ${rawKey} is not covered by any granted capability`
1435
+ );
1436
+ }
1437
+ selectedResourceActionPairs.add(canonical);
1438
+ }
1439
+ if (nonRequiredGrantedPairs.size > 0) {
1440
+ const missingPairs = [];
1441
+ for (const pair of nonRequiredGrantedPairs) {
1442
+ if (!selectedResourceActionPairs.has(pair)) {
1443
+ missingPairs.push(pair);
1444
+ }
1445
+ }
1446
+ if (missingPairs.length > 0) {
1447
+ const previewLimit = 5;
1448
+ const preview = missingPairs.slice(0, previewLimit).map((p) => p.replace("\0", "::")).join(", ");
1449
+ const overflow = missingPairs.length > previewLimit ? ` (and ${missingPairs.length - previewLimit} more)` : "";
1450
+ throw new Error(
1451
+ `OpenKey selectedActionKeys is missing entries for signedMessage capabilities: ${preview}${overflow}`
1452
+ );
1453
+ }
1454
+ }
1455
+ if (allSignedGrantedPairs.size > 0 && result.permissions.length === 0) {
1456
+ throw new Error(
1457
+ "OpenKey permissions is empty but signedMessage carries capabilities \u2014 refusing to accept a session without an authoritative grant list"
1458
+ );
1459
+ }
1460
+ const permissionCanonicalKeys = /* @__PURE__ */ new Set();
1461
+ const permissionPairsCovered = /* @__PURE__ */ new Set();
1462
+ for (const perm of result.permissions) {
1463
+ const { service, space, path, actions } = perm;
1464
+ if (typeof service !== "string" || typeof space !== "string" || typeof path !== "string") {
1465
+ throw new Error(
1466
+ `OpenKey permissions entry has non-string service/space/path (service=${service}, space=${space}, path=${path})`
1467
+ );
1468
+ }
1469
+ if (!Array.isArray(actions) || actions.length === 0) {
1470
+ throw new Error(
1471
+ `OpenKey permissions entry (${service} ${space} ${path}) has empty actions \u2014 a capability-bearing entry must list at least one action`
1472
+ );
1473
+ }
1474
+ const canonicalKey = `${service}\0${space}\0${path}`;
1475
+ if (permissionCanonicalKeys.has(canonicalKey)) {
1476
+ throw new Error(
1477
+ `OpenKey permissions has duplicate entry for (${service} ${space} ${path})`
1478
+ );
1479
+ }
1480
+ permissionCanonicalKeys.add(canonicalKey);
1481
+ let expectedResource;
1482
+ if (space.startsWith("tinycloud:")) {
1483
+ const shortService = service.startsWith("tinycloud.") ? service.slice("tinycloud.".length) : service;
1484
+ expectedResource = path ? `${space}/${shortService}/${path}` : `${space}/${shortService}`;
1485
+ } else {
1486
+ if (path) {
1487
+ throw new Error(
1488
+ `OpenKey permissions entry (${service} ${space} ${path}) carries a path on a non-tinycloud: resource URI, which is not a recognized encoding`
1489
+ );
1490
+ }
1491
+ expectedResource = space;
1492
+ }
1493
+ let matchedResource;
1494
+ if (signedCaps[expectedResource] !== void 0) {
1495
+ matchedResource = expectedResource;
1496
+ } else {
1497
+ throw new Error(
1498
+ `OpenKey permissions entry (${service} ${space} ${path}) claims grants not confirmed in signedMessage capabilities`
1499
+ );
1500
+ }
1501
+ const grantedActionsForResource = new Set(
1502
+ Object.keys(signedCaps[matchedResource])
1503
+ );
1504
+ for (const action of actions) {
1505
+ if (typeof action !== "string" || !action) {
1506
+ throw new Error(
1507
+ `OpenKey permissions entry (${service} ${space} ${path}) has non-string action ${String(action)}`
1508
+ );
1509
+ }
1510
+ if (!grantedActionsForResource.has(action)) {
1511
+ throw new Error(
1512
+ `OpenKey permissions entry (${service} ${space} ${path}) claims action ${action} not present in signedMessage capabilities`
1513
+ );
1514
+ }
1515
+ permissionPairsCovered.add(`${matchedResource}\0${action}`);
1516
+ }
1517
+ }
1518
+ if (allSignedGrantedPairs.size > 0) {
1519
+ const missingFromPerms = [];
1520
+ for (const pair of allSignedGrantedPairs) {
1521
+ if (!permissionPairsCovered.has(pair)) {
1522
+ missingFromPerms.push(pair);
1523
+ }
1524
+ }
1525
+ if (missingFromPerms.length > 0) {
1526
+ const previewLimit = 5;
1527
+ const preview = missingFromPerms.slice(0, previewLimit).map((p) => p.replace("\0", "::")).join(", ");
1528
+ const overflow = missingFromPerms.length > previewLimit ? ` (and ${missingFromPerms.length - previewLimit} more)` : "";
1529
+ throw new Error(
1530
+ `OpenKey permissions is missing entries for signedMessage capabilities: ${preview}${overflow}`
1531
+ );
1532
+ }
1533
+ const extrasInPerms = [];
1534
+ for (const pair of permissionPairsCovered) {
1535
+ if (!allSignedGrantedPairs.has(pair)) {
1536
+ extrasInPerms.push(pair);
1537
+ }
1538
+ }
1539
+ if (extrasInPerms.length > 0) {
1540
+ const previewLimit = 5;
1541
+ const preview = extrasInPerms.slice(0, previewLimit).map((p) => p.replace("\0", "::")).join(", ");
1542
+ const overflow = extrasInPerms.length > previewLimit ? ` (and ${extrasInPerms.length - previewLimit} more)` : "";
1543
+ throw new Error(
1544
+ `OpenKey permissions contains entries not present in the signed authority: ${preview}${overflow}`
1545
+ );
1546
+ }
1547
+ }
1548
+ void nonRequiredGrantedPairs;
1549
+ return this.signInWithPreparedSession(
1550
+ {
1551
+ siwe: result.signedMessage,
1552
+ jwk: prepared.jwk,
1553
+ spaceId: prepared.spaceId,
1554
+ verificationMethod: prepared.verificationMethod
1555
+ },
1556
+ result.signature,
1557
+ keyId,
1558
+ jwk
1559
+ );
1560
+ }
1128
1561
  /**
1129
1562
  * Clear persisted session data.
1130
1563
  */
@@ -1439,7 +1872,7 @@ function createWasmKeyProvider(sessionManager) {
1439
1872
  // src/delegateToHelpers.ts
1440
1873
  import {
1441
1874
  parseExpiry,
1442
- SiweMessage,
1875
+ SiweMessage as SiweMessage2,
1443
1876
  EXPIRY as EXPIRY2
1444
1877
  } from "@tinycloud/sdk-core";
1445
1878
  function legacyParamsToPermissionEntries(actions, path, spaceIdOverride) {
@@ -1488,7 +1921,7 @@ function resolveExpiryMs(expiry) {
1488
1921
  return parseExpiry(expiry);
1489
1922
  }
1490
1923
  function extractSiweExpiration(siwe) {
1491
- const parsed = new SiweMessage(siwe);
1924
+ const parsed = new SiweMessage2(siwe);
1492
1925
  if (parsed.expirationTime === void 0 || parsed.expirationTime === null) {
1493
1926
  return void 0;
1494
1927
  }
@@ -2000,7 +2433,9 @@ function base64UrlDecode(value) {
2000
2433
  return new Uint8Array(bytes);
2001
2434
  }
2002
2435
  async function signJwtInputWithJwk(signingInput, jwk) {
2003
- const bytes = new TextEncoder().encode(signingInput);
2436
+ return signBytesWithJwk(new TextEncoder().encode(signingInput), jwk);
2437
+ }
2438
+ async function signBytesWithJwk(bytes, jwk) {
2004
2439
  try {
2005
2440
  const subtle = globalThis.crypto?.subtle;
2006
2441
  if (!subtle) {
@@ -2081,6 +2516,12 @@ var _TinyCloudNode = class _TinyCloudNode {
2081
2516
  this._baseSecrets = /* @__PURE__ */ new Map();
2082
2517
  this._secrets = /* @__PURE__ */ new Map();
2083
2518
  this.runtimePermissionGrants = [];
2519
+ /**
2520
+ * Owned space ids this sign-in has already confirmed are hosted. Consulted by
2521
+ * {@link ensureOwnedSpaceHostedById}; cleared on every {@link signIn} because
2522
+ * hosting is confirmed against the session that was active at the time.
2523
+ */
2524
+ this.confirmedHostedSpaceIds = /* @__PURE__ */ new Set();
2084
2525
  /**
2085
2526
  * True when the last signIn() detected an interactive signer and skipped
2086
2527
  * client-side bootstrap. Apps can read this to know whether bootstrap was
@@ -2091,7 +2532,10 @@ var _TinyCloudNode = class _TinyCloudNode {
2091
2532
  * Outcome of the last signIn()'s account-bootstrap attempt. `skipped` is
2092
2533
  * true when bootstrap did not complete (interactive signer, auto-sign
2093
2534
  * denied, or a bootstrap step failed); `reason` carries the cause so apps
2094
- * can surface a "finish account setup" call-to-action.
2535
+ * can surface a "finish account setup" call-to-action. `warnings` is
2536
+ * present when bootstrap completed, but one or more steps recovered from
2537
+ * an ambiguous failure (e.g. a KV batch write reconciled via per-space
2538
+ * fallback) — clean vs. recovered runs are otherwise byte-identical.
2095
2539
  */
2096
2540
  this._bootstrapStatus = {
2097
2541
  skipped: false
@@ -2477,6 +2921,7 @@ var _TinyCloudNode = class _TinyCloudNode {
2477
2921
  this._spaceService = void 0;
2478
2922
  this._serviceContext = void 0;
2479
2923
  this.runtimePermissionGrants = [];
2924
+ this.confirmedHostedSpaceIds.clear();
2480
2925
  await this.tc.signIn(options);
2481
2926
  this.syncResolvedHostFromAuth();
2482
2927
  const oldGraph = this._serviceGraph;
@@ -2531,7 +2976,13 @@ var _TinyCloudNode = class _TinyCloudNode {
2531
2976
  return false;
2532
2977
  }
2533
2978
  try {
2534
- await this.runAccountBootstrap(steps);
2979
+ const warnings = await this.runAccountBootstrap(steps);
2980
+ if (warnings.length > 0) {
2981
+ this._bootstrapStatus = { skipped: false, warnings };
2982
+ this.notificationHandler.warning(
2983
+ `Account bootstrap completed with warnings: ${warnings.map((w) => `${w.stepId} (${w.kind}: ${w.code})`).join("; ")}`
2984
+ );
2985
+ }
2535
2986
  } catch (err) {
2536
2987
  const reason = err instanceof Error ? err.message : String(err);
2537
2988
  this._bootstrapSkipped = true;
@@ -2580,6 +3031,7 @@ var _TinyCloudNode = class _TinyCloudNode {
2580
3031
  const auth = this.auth;
2581
3032
  const sessions = /* @__PURE__ */ new Map();
2582
3033
  const rawAbilitiesBySpace = /* @__PURE__ */ new Map();
3034
+ const warnings = [];
2583
3035
  const primarySession = auth.tinyCloudSession;
2584
3036
  const defaultSpaceId = this.ownedSpaceId("default");
2585
3037
  const canReusePrimaryBootstrapSession = primarySession?.spaceId === defaultSpaceId && auth.capabilityRequest === BOOTSTRAP_SESSION_REQUESTS.default;
@@ -2641,32 +3093,45 @@ var _TinyCloudNode = class _TinyCloudNode {
2641
3093
  }
2642
3094
  }
2643
3095
  if (step.kind === "seed-spaces") {
2644
- for (const space of step.spaces) {
2645
- const registered = await this.account.spaces.register({
3096
+ const registered = await this.account.spaces.registerBatch(
3097
+ step.spaces.map((space) => ({
2646
3098
  spaceId: space.spaceId,
2647
3099
  name: space.name,
2648
3100
  ownerDid: this.did,
2649
3101
  type: "owned",
2650
3102
  permissions: ["*"],
2651
3103
  status: "active"
3104
+ }))
3105
+ );
3106
+ if (!registered.ok) {
3107
+ throw new Error(
3108
+ `Failed to seed account spaces: ${registered.error.message}`
3109
+ );
3110
+ }
3111
+ const batchError = registered.data.recoveredFromBatchError;
3112
+ if (batchError) {
3113
+ warnings.push({
3114
+ stepId: step.id,
3115
+ kind: "batch-write-reconciled",
3116
+ code: batchError.code,
3117
+ message: batchError.message
2652
3118
  });
2653
- if (!registered.ok) {
2654
- throw new Error(
2655
- `Failed to seed account space ${space.spaceId}: ${registered.error.message}`
2656
- );
2657
- }
2658
3119
  }
2659
3120
  }
2660
3121
  if (step.kind === "seed-applications") {
2661
3122
  const registered = await this.account.applications.register(
2662
- step.manifests.length > 0 ? [...step.manifests] : TINYCLOUD_SECRETS_BOOTSTRAP_MANIFEST
3123
+ step.manifests.length > 0 ? [...step.manifests] : TINYCLOUD_SECRETS_BOOTSTRAP_MANIFEST,
3124
+ // Bootstrap only runs on an account with no registry records, so the
3125
+ // manifest-hash pre-read is definitionally a miss. Skip it; the write
3126
+ // it guards is an INSERT OR REPLACE and is safe to repeat.
3127
+ { assumeUnregistered: true }
2663
3128
  );
2664
3129
  if (!registered.ok) {
2665
3130
  throw new Error(`Failed to seed bootstrap applications: ${registered.error.message}`);
2666
3131
  }
2667
3132
  }
2668
3133
  if (step.kind === "encryption-network-create") {
2669
- await this.ensureEncryptionNetwork(step.networkId);
3134
+ await this.ensureEncryptionNetwork(step.networkId, { assumeMissing: true });
2670
3135
  }
2671
3136
  if (step.kind === "secret-records-schema") {
2672
3137
  const db = this.sqlForSpace(step.spaceId).db(step.database);
@@ -2686,6 +3151,7 @@ var _TinyCloudNode = class _TinyCloudNode {
2686
3151
  }
2687
3152
  }
2688
3153
  }
3154
+ return warnings;
2689
3155
  }
2690
3156
  registerBootstrapRuntimeGrant(session, request, rawAbilities) {
2691
3157
  const operations = [];
@@ -2945,6 +3411,22 @@ var _TinyCloudNode = class _TinyCloudNode {
2945
3411
  await this.ensureEncryptionNetwork(networkId);
2946
3412
  }
2947
3413
  }
3414
+ /**
3415
+ * Ensure one of this user's owned spaces is hosted, at most once per space
3416
+ * per sign-in.
3417
+ *
3418
+ * Account bootstrap funnels six calls through here (via
3419
+ * `AccountService.ensureAccountSpaceHosted`), and each one re-submits
3420
+ * `activateSessionWithHost` with the *primary* session. That session's recap
3421
+ * covers only `default` and `secrets`, so the account space appears in
3422
+ * neither `activated` nor `skipped` and the guard below returns immediately
3423
+ * — the repeat calls cannot change the outcome, they only cost round trips.
3424
+ *
3425
+ * The memo is per sign-in ({@link confirmedHostedSpaceIds} is cleared in
3426
+ * `signIn`) and only records confirmed successes. A wrong skip is
3427
+ * self-revealing: the next write to the space fails immediately and loudly
3428
+ * with `404 Space not found`.
3429
+ */
2948
3430
  async ensureOwnedSpaceHostedById(spaceId) {
2949
3431
  if (!this.auth) {
2950
3432
  throw new Error("Owned space hosting requires wallet mode");
@@ -2953,12 +3435,16 @@ var _TinyCloudNode = class _TinyCloudNode {
2953
3435
  if (!session) {
2954
3436
  throw new Error("Owned space hosting requires an active session");
2955
3437
  }
3438
+ if (this.confirmedHostedSpaceIds.has(spaceId)) {
3439
+ return;
3440
+ }
2956
3441
  const host = this.hosts[0] ?? this.config.host;
2957
3442
  if (!host) {
2958
3443
  throw new Error("Owned space hosting requires a TinyCloud host");
2959
3444
  }
2960
3445
  const activation = await activateSessionWithHost2(host, session.delegationHeader);
2961
3446
  if (activation.success && !activation.skipped?.includes(spaceId)) {
3447
+ this.confirmedHostedSpaceIds.add(spaceId);
2962
3448
  return;
2963
3449
  }
2964
3450
  if (!activation.success && activation.status !== 404) {
@@ -2977,6 +3463,7 @@ var _TinyCloudNode = class _TinyCloudNode {
2977
3463
  `Failed to activate session after creating owned space ${spaceId}: ${retry.error ?? "space was skipped"}`
2978
3464
  );
2979
3465
  }
3466
+ this.confirmedHostedSpaceIds.add(spaceId);
2980
3467
  }
2981
3468
  /**
2982
3469
  * Host one of this user's owned spaces by name (e.g. `"applications"`).
@@ -3292,6 +3779,7 @@ var _TinyCloudNode = class _TinyCloudNode {
3292
3779
  this._delegationManager = stagedGraph.delegationManager;
3293
3780
  this._spaceService = stagedGraph.spaceService;
3294
3781
  this._serviceGraph = stagedGraph.graph;
3782
+ this._activeServiceSession = serviceSession;
3295
3783
  this._baseSecrets = /* @__PURE__ */ new Map();
3296
3784
  this._secrets = /* @__PURE__ */ new Map();
3297
3785
  this._publicKV = void 0;
@@ -3299,6 +3787,7 @@ var _TinyCloudNode = class _TinyCloudNode {
3299
3787
  this.tc = stagedGraph.core;
3300
3788
  this._recapOperationsCache = stagedPrimary.cache;
3301
3789
  this.runtimePermissionGrants = stagedPrimary.grants;
3790
+ this.confirmedHostedSpaceIds.clear();
3302
3791
  if (this.auth) {
3303
3792
  this.auth.installRestoredSession(stagedManager, stagedTcSession, [stagedHost]);
3304
3793
  this._restoredTcSession = void 0;
@@ -3608,6 +4097,28 @@ var _TinyCloudNode = class _TinyCloudNode {
3608
4097
  currentTinyCloudSession() {
3609
4098
  return this.auth?.tinyCloudSession ?? this._restoredTcSession;
3610
4099
  }
4100
+ /**
4101
+ * Mint a multi-capability invocation through the established session key.
4102
+ * Callers receive only the signed headers; session key material remains
4103
+ * owned by the session manager and is never part of the caller contract.
4104
+ */
4105
+ invokeAny(entries, facts) {
4106
+ const session = this.currentTinyCloudSession() ?? this._activeServiceSession;
4107
+ if (session === void 0) throw new Error("Not signed in. Call signIn() first.");
4108
+ return this._serviceGraph.invokeAny(session, entries, facts);
4109
+ }
4110
+ /** Sign protocol bytes with the established in-memory session key. */
4111
+ async signSessionBytes(bytes) {
4112
+ const session = this.currentTinyCloudSession() ?? this._activeServiceSession;
4113
+ if (session === void 0) throw new Error("Not signed in. Call signIn() first.");
4114
+ return signBytesWithJwk(bytes, session.jwk);
4115
+ }
4116
+ /** Bind a session-signed invocation to the canonical service audience. */
4117
+ async bindInvocationAudience(authorization, audience) {
4118
+ const session = this.currentTinyCloudSession() ?? this._activeServiceSession;
4119
+ if (session === void 0) throw new Error("Not signed in. Call signIn() first.");
4120
+ return rewriteInvocationAudience(authorization, audience, session.jwk);
4121
+ }
3611
4122
  /**
3612
4123
  * Connect a wallet to upgrade from session-only mode to wallet mode.
3613
4124
  *
@@ -3789,6 +4300,7 @@ var _TinyCloudNode = class _TinyCloudNode {
3789
4300
  if (this._serviceContext) {
3790
4301
  const spaceScopedContext = this._serviceGraph.track(new ServiceContext2({
3791
4302
  invoke: this._serviceContext.invoke,
4303
+ invokeAny: this._serviceContext.invokeAny,
3792
4304
  fetch: this._serviceContext.fetch,
3793
4305
  hosts: this._serviceContext.hosts,
3794
4306
  telemetry: this.config.telemetry
@@ -3881,7 +4393,17 @@ var _TinyCloudNode = class _TinyCloudNode {
3881
4393
  verifyNodeSignature: (nodeId, message, signature) => verifyDidKeyEd25519Signature(nodeId, message, signature)
3882
4394
  };
3883
4395
  }
4396
+ /**
4397
+ * The node DID for {@link config.host}.
4398
+ *
4399
+ * Sign-in already performs `GET /info` for the protocol-version check, and
4400
+ * that response carries `nodeId`, so reuse it rather than fetching `/info` a
4401
+ * second time. Only a value recorded for this exact host qualifies; older
4402
+ * nodes that omit `nodeId` fall through to the fetch.
4403
+ */
3884
4404
  async fetchNodeId() {
4405
+ const cached = this.auth?.nodeIdForHost(this.config.host);
4406
+ if (cached) return cached;
3885
4407
  const response = await fetch(`${this.config.host}/info`);
3886
4408
  if (!response.ok) {
3887
4409
  throw new Error(`Failed to fetch node info: HTTP ${response.status}`);
@@ -4444,6 +4966,7 @@ var _TinyCloudNode = class _TinyCloudNode {
4444
4966
  shareUrl: input.shareUrl,
4445
4967
  documentName: input.documentName,
4446
4968
  jti: base64UrlEncode(crypto.getRandomValues(new Uint8Array(16))),
4969
+ idempotencyKey: input.idempotencyKey,
4447
4970
  expiresAt: input.expiresAt
4448
4971
  };
4449
4972
  const requestBodyDigest = base64UrlEncode(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonicalizeEncryptionJson(body)))));
@@ -4653,6 +5176,13 @@ var _TinyCloudNode = class _TinyCloudNode {
4653
5176
  body
4654
5177
  )
4655
5178
  });
5179
+ if (response.status === 409) {
5180
+ const existing = await this.getEncryptionNetwork(networkId);
5181
+ if (existing) return existing;
5182
+ throw new Error(
5183
+ `Encryption network ${networkId} already exists but could not be read back`
5184
+ );
5185
+ }
4656
5186
  if (!response.ok) {
4657
5187
  throw new Error(
4658
5188
  `Failed to create encryption network ${networkId}: HTTP ${response.status} ${await response.text()}`
@@ -4661,11 +5191,19 @@ var _TinyCloudNode = class _TinyCloudNode {
4661
5191
  const created = await response.json();
4662
5192
  return created.descriptor;
4663
5193
  }
4664
- async ensureEncryptionNetwork(nameOrNetworkId = DEFAULT_ENCRYPTION_NETWORK_NAME) {
5194
+ /**
5195
+ * Ensure an encryption network exists, creating it if necessary.
5196
+ *
5197
+ * @param nameOrNetworkId - Network name or full `urn:tinycloud:encryption:` id.
5198
+ * @param options - See {@link EnsureEncryptionNetworkOptions}.
5199
+ */
5200
+ async ensureEncryptionNetwork(nameOrNetworkId = DEFAULT_ENCRYPTION_NETWORK_NAME, options = {}) {
4665
5201
  const networkId = nameOrNetworkId.startsWith("urn:tinycloud:encryption:") ? nameOrNetworkId : this.getDefaultEncryptionNetworkId(nameOrNetworkId);
4666
- const existing = await this.getEncryptionNetwork(networkId);
4667
- if (existing) {
4668
- return existing;
5202
+ if (!options.assumeMissing) {
5203
+ const existing = await this.getEncryptionNetwork(networkId);
5204
+ if (existing) {
5205
+ return existing;
5206
+ }
4669
5207
  }
4670
5208
  const parsed = parseNetworkId2(networkId);
4671
5209
  if (!didPrincipalMatches2(parsed.ownerDid, this.did)) {