@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/index.cjs CHANGED
@@ -17165,7 +17165,8 @@ __export(index_exports, {
17165
17165
  tinyCloudDebugLogger: () => import_sdk_core11.tinyCloudDebugLogger,
17166
17166
  validateEnvelope: () => import_sdk_core17.validateEnvelope,
17167
17167
  validateManifest: () => import_sdk_core12.validateManifest,
17168
- verifyDecryptResponse: () => import_sdk_core17.verifyDecryptResponse
17168
+ verifyDecryptResponse: () => import_sdk_core17.verifyDecryptResponse,
17169
+ wireOpenKeyAuthorize: () => wireOpenKeyAuthorize
17169
17170
  });
17170
17171
  module.exports = __toCommonJS(index_exports);
17171
17172
 
@@ -17278,6 +17279,8 @@ var import_sdk_core7 = require("@tinycloud/sdk-core");
17278
17279
  var import_sdk_services = require("@tinycloud/sdk-services");
17279
17280
 
17280
17281
  // src/authorization/NodeUserAuthorization.ts
17282
+ var import_node_crypto = require("crypto");
17283
+ var import_siwe = require("siwe");
17281
17284
  var import_sdk_core2 = require("@tinycloud/sdk-core");
17282
17285
 
17283
17286
  // src/authorization/strategies.ts
@@ -17358,6 +17361,27 @@ var MemorySessionStorage = class {
17358
17361
  // src/authorization/NodeUserAuthorization.ts
17359
17362
  var DECRYPT_ACTION = import_sdk_core2.ENCRYPTION.DECRYPT;
17360
17363
  var NETWORK_CREATE_ACTION = import_sdk_core2.ENCRYPTION.NETWORK_CREATE;
17364
+ function canonicalizeOpenKeyManifestJson(value) {
17365
+ const json = JSON.stringify(value);
17366
+ if (json === void 0) {
17367
+ throw new TypeError("manifest is not JSON-serializable");
17368
+ }
17369
+ return canonicalizeJsonValue(JSON.parse(json));
17370
+ }
17371
+ function canonicalizeJsonValue(value) {
17372
+ if (value === null || typeof value !== "object") {
17373
+ return JSON.stringify(value);
17374
+ }
17375
+ if (Array.isArray(value)) {
17376
+ return `[${value.map(canonicalizeJsonValue).join(",")}]`;
17377
+ }
17378
+ return `{${Object.keys(value).sort().map(
17379
+ (key2) => `${JSON.stringify(key2)}:${canonicalizeJsonValue(value[key2])}`
17380
+ ).join(",")}}`;
17381
+ }
17382
+ function canonicalOpenKeyManifestSha256Hex(value) {
17383
+ return (0, import_node_crypto.createHash)("sha256").update(canonicalizeOpenKeyManifestJson(value)).digest("hex");
17384
+ }
17361
17385
  function didPrincipalMatches(actual, expected) {
17362
17386
  try {
17363
17387
  return (0, import_sdk_core2.principalDidEquals)(actual, expected);
@@ -17578,6 +17602,15 @@ var NodeUserAuthorization = class {
17578
17602
  get nodeFeatures() {
17579
17603
  return this._nodeFeatures;
17580
17604
  }
17605
+ /**
17606
+ * The node DID reported by `GET /info` for `host`, if the sign-in (or
17607
+ * restore) that populated it targeted that same host. Returns `undefined`
17608
+ * for any other host, and for nodes whose `/info` omits `nodeId`, so callers
17609
+ * fall back to fetching it themselves rather than using a foreign node's DID.
17610
+ */
17611
+ nodeIdForHost(host) {
17612
+ return this._nodeInfoIdentity?.host === host ? this._nodeInfoIdentity.nodeId : void 0;
17613
+ }
17581
17614
  get lastActivationSkippedSpaceIds() {
17582
17615
  return [...this._lastActivationSkippedSpaceIds];
17583
17616
  }
@@ -18047,11 +18080,13 @@ var NodeUserAuthorization = class {
18047
18080
  this._tinyCloudSession = tinyCloudSession;
18048
18081
  this._address = address;
18049
18082
  this._chainId = chainId;
18083
+ const infoHost = this.primaryTinyCloudHost;
18050
18084
  const nodeInfo = await (0, import_sdk_core2.checkNodeInfo)(
18051
- this.primaryTinyCloudHost,
18085
+ infoHost,
18052
18086
  this.wasm.protocolVersion()
18053
18087
  );
18054
18088
  this._nodeFeatures = nodeInfo.features;
18089
+ this._nodeInfoIdentity = nodeInfo.nodeId ? { host: infoHost, nodeId: nodeInfo.nodeId } : void 0;
18055
18090
  for (const ext of this.extensions) {
18056
18091
  if (ext.afterSignIn) {
18057
18092
  await ext.afterSignIn(clientSession);
@@ -18217,11 +18252,13 @@ var NodeUserAuthorization = class {
18217
18252
  this._tinyCloudSession = tinyCloudSession;
18218
18253
  this._address = address;
18219
18254
  this._chainId = chainId;
18255
+ const infoHost = this.primaryTinyCloudHost;
18220
18256
  const nodeInfo = await (0, import_sdk_core2.checkNodeInfo)(
18221
- this.primaryTinyCloudHost,
18257
+ infoHost,
18222
18258
  this.wasm.protocolVersion()
18223
18259
  );
18224
18260
  this._nodeFeatures = nodeInfo.features;
18261
+ this._nodeInfoIdentity = nodeInfo.nodeId ? { host: infoHost, nodeId: nodeInfo.nodeId } : void 0;
18225
18262
  for (const ext of this.extensions) {
18226
18263
  if (ext.afterSignIn) {
18227
18264
  await ext.afterSignIn(clientSession);
@@ -18230,6 +18267,398 @@ var NodeUserAuthorization = class {
18230
18267
  await this.ensureSpaceExists();
18231
18268
  return clientSession;
18232
18269
  }
18270
+ /**
18271
+ * End-to-end sign-in via OpenKey `authorizeTinyCloud()` (Sol CRITICAL-4).
18272
+ *
18273
+ * This is the production entry point that wires
18274
+ * {@link prepareSessionForSigning} → OpenKey `authorizeTinyCloud()` →
18275
+ * {@link signInWithOpenKeyResult} into one call. Consumers pass in a
18276
+ * caller-provided `authorizeFn` (typically a thin wrapper around
18277
+ * `openkey.authorizeTinyCloud()`) so this method has no direct
18278
+ * dependency on the OpenKey SDK — it works with any transport that
18279
+ * conforms to the versioned protocol.
18280
+ *
18281
+ * The `authorizeFn` MUST:
18282
+ * 1. Send the prepared SIWE bytes to OpenKey.
18283
+ * 2. Return the exact `TinyCloudAuthorizationResultV1` shape (with
18284
+ * `signedMessage` — the bytes the signature actually verifies
18285
+ * against — plus `selectedActionKeys` and `permissions`).
18286
+ *
18287
+ * NodeUserAuthorization then completes the session with `signedMessage`
18288
+ * (never the prepared bytes), verifying the signature, the immutable
18289
+ * SIWE fields, and every subset invariant enforced by
18290
+ * {@link signInWithOpenKeyResult}. This preserves the exact-byte
18291
+ * guarantee: if the user did NOT narrow, the returned `signedMessage`
18292
+ * equals the prepared SIWE byte-for-byte; if the user narrowed, only
18293
+ * the ReCap block differs and every immutable header field survives.
18294
+ *
18295
+ * Legacy exact-byte {@link signIn} callers are unaffected — this method
18296
+ * is opt-in.
18297
+ *
18298
+ * @param authorizeFn Callback that invokes the versioned OpenKey
18299
+ * `authorizeTinyCloud()` protocol and returns the result. Receives
18300
+ * the prepared SIWE bytes plus a `host` hint the widget should bind
18301
+ * into its /authorize-sign-prepare context.
18302
+ * @param options Optional overrides for the OpenKey request.
18303
+ */
18304
+ async signInWithOpenKey(authorizeFn, options) {
18305
+ if (options?.host !== void 0) {
18306
+ const overrideHost = options.host.trim();
18307
+ if (!overrideHost) {
18308
+ throw new Error("OpenKey authorization host must not be empty");
18309
+ }
18310
+ this.tinycloudHosts = [overrideHost];
18311
+ }
18312
+ const signingAddress = (0, import_sdk_core2.canonicalizeAddress)(await this.signer.getAddress());
18313
+ const signingChainId = await this.signer.getChainId();
18314
+ await this.resolveTinyCloudHostsForSignIn(signingAddress, signingChainId);
18315
+ const { prepared, keyId, jwk } = await this.prepareSessionForSigning();
18316
+ const presentation = this.buildPresentationEnvelope(options?.reason);
18317
+ const hostHint = this.primaryTinyCloudHost;
18318
+ const result = await authorizeFn({
18319
+ protocolVersion: 1,
18320
+ siwe: prepared.siwe,
18321
+ jwk,
18322
+ host: hostHint,
18323
+ keyId: options?.openkeyKeyId,
18324
+ presentation
18325
+ });
18326
+ return this.signInWithOpenKeyResult(result, prepared, keyId, jwk);
18327
+ }
18328
+ /**
18329
+ * Build a `CapabilityPresentationEnvelopeV1` from `this._manifest`. The
18330
+ * envelope is display-only — the OpenKey widget renders it under an
18331
+ * honest trust label, and the server /authorize-sign-prepare route
18332
+ * decides whether to upgrade the label to `origin-bound` by fetching
18333
+ * the well-known manifest and comparing its digest to
18334
+ * `manifestDigest`.
18335
+ *
18336
+ * Returns `undefined` when no manifest is set — the widget renders as
18337
+ * "no manifest supplied" (unsigned).
18338
+ *
18339
+ * The digest is a deterministic SHA-256 over canonical JSON of the
18340
+ * first manifest payload; OpenKey parses the fetched well-known manifest
18341
+ * and applies the same canonicalization before comparing digests.
18342
+ */
18343
+ buildPresentationEnvelope(reason) {
18344
+ const manifests = Array.isArray(this._manifest) ? this._manifest : this._manifest ? [this._manifest] : [];
18345
+ if (manifests.length === 0 && !reason) {
18346
+ return void 0;
18347
+ }
18348
+ const primary = manifests[0];
18349
+ let displayName;
18350
+ let manifestId;
18351
+ let manifestDigest;
18352
+ const envelopeManifests = [];
18353
+ for (const m of manifests) {
18354
+ envelopeManifests.push({
18355
+ name: m.name,
18356
+ appId: m.app_id,
18357
+ // Deep-copy to prevent later mutations from affecting the digest.
18358
+ payload: JSON.parse(JSON.stringify(m))
18359
+ });
18360
+ }
18361
+ if (primary) {
18362
+ displayName = primary.name;
18363
+ manifestId = primary.app_id;
18364
+ manifestDigest = canonicalOpenKeyManifestSha256Hex(primary);
18365
+ }
18366
+ return {
18367
+ protocolVersion: 1,
18368
+ displayName,
18369
+ reason: reason && reason.length > 0 ? reason : void 0,
18370
+ manifestId,
18371
+ manifestDigest,
18372
+ manifests: envelopeManifests.length > 0 ? envelopeManifests : void 0
18373
+ };
18374
+ }
18375
+ /**
18376
+ * Complete sign-in with a versioned OpenKey authorization result.
18377
+ *
18378
+ * Where `signInWithPreparedSession` completes with a signature over the
18379
+ * ORIGINAL prepared SIWE, this method completes with the SIWE bytes the
18380
+ * OpenKey widget actually presented and signed (`signedMessage`). It
18381
+ * exists because the versioned authorizeTinyCloud protocol lets the user
18382
+ * narrow capabilities; the returned bytes may differ from the original
18383
+ * request and are the only ones the signature actually verifies against.
18384
+ *
18385
+ * The `result` parameter is validated at the trust boundary via
18386
+ * `validateAuthorizationResultV1` (in sdk-core). Callers should validate
18387
+ * BEFORE calling this method so unknown/missing fields are rejected
18388
+ * before any session state is created.
18389
+ *
18390
+ * Validation performed here (defence-in-depth: even a "valid" v1 payload
18391
+ * must clear these checks before any session state is created):
18392
+ * 1. `result.address` matches the local signer's address.
18393
+ * 2. `result.signature` recovers to `result.address` when verified against
18394
+ * `result.signedMessage`.
18395
+ * 3. Every immutable SIWE header field (domain, address, URI, version,
18396
+ * chainId, nonce, issuedAt) in the original prepared SIWE is preserved
18397
+ * byte-for-byte in `result.signedMessage`. `statement` is NOT in this
18398
+ * list because the WASM renders it from the ReCap contents; it MUST
18399
+ * change when the ReCap is narrowed.
18400
+ * 4. Every capability in `result.signedMessage`'s ReCap block was present
18401
+ * in the original prepared SIWE — narrowing OK, broadening rejected.
18402
+ * 5. Every `result.selectedActionKeys` entry corresponds to a
18403
+ * (resource, action) pair in the returned SIWE.
18404
+ */
18405
+ async signInWithOpenKeyResult(result, prepared, keyId, jwk) {
18406
+ if (result.protocolVersion !== 1) {
18407
+ throw new Error(`Unsupported OpenKey protocol version ${result.protocolVersion}`);
18408
+ }
18409
+ if (!result.signedMessage) {
18410
+ throw new Error("OpenKey result must include signedMessage");
18411
+ }
18412
+ if (!prepared.siwe) {
18413
+ throw new Error(
18414
+ "signInWithOpenKeyResult requires prepared.siwe (the SDK-generated SIWE the sign-in was proposed with)"
18415
+ );
18416
+ }
18417
+ const expectedAddress = (0, import_sdk_core2.canonicalizeAddress)(await this.signer.getAddress());
18418
+ if ((0, import_sdk_core2.canonicalizeAddress)(result.address) !== expectedAddress) {
18419
+ throw new Error(
18420
+ `OpenKey returned signature from ${result.address} but expected ${expectedAddress}`
18421
+ );
18422
+ }
18423
+ let parsedSignedSiwe;
18424
+ try {
18425
+ parsedSignedSiwe = new import_siwe.SiweMessage(result.signedMessage);
18426
+ } catch (err) {
18427
+ throw new Error(
18428
+ `OpenKey signedMessage is not a parseable SIWE: ${err.message}`
18429
+ );
18430
+ }
18431
+ const verifyResult = await parsedSignedSiwe.verify(
18432
+ { signature: result.signature },
18433
+ { suppressExceptions: true }
18434
+ );
18435
+ if (!verifyResult.success) {
18436
+ const errData = verifyResult.error;
18437
+ const type = errData && typeof errData === "object" && "type" in errData ? errData.type : void 0;
18438
+ const details = errData && typeof errData === "object" && "expected" in errData ? ` (expected=${errData.expected}, received=${errData.received})` : "";
18439
+ throw new Error(
18440
+ `OpenKey signature verification failed${type ? `: ${type}` : ""}${details}`
18441
+ );
18442
+ }
18443
+ if ((0, import_sdk_core2.canonicalizeAddress)(parsedSignedSiwe.address) !== expectedAddress) {
18444
+ throw new Error(
18445
+ `OpenKey signedMessage.address (${parsedSignedSiwe.address}) does not match local signer address (${expectedAddress})`
18446
+ );
18447
+ }
18448
+ const originalHasRecap = /(?:^|\n)-\s+urn:recap:/i.test(prepared.siwe);
18449
+ const originalFields = (0, import_sdk_core2.extractImmutableSiweFields)(prepared.siwe);
18450
+ const signedFields = (0, import_sdk_core2.extractImmutableSiweFields)(result.signedMessage);
18451
+ const changedFields = (0, import_sdk_core2.diffImmutableSiweFields)(originalFields, signedFields, {
18452
+ originalHasRecap
18453
+ });
18454
+ if (changedFields.length > 0) {
18455
+ throw new Error(
18456
+ `OpenKey signedMessage altered immutable SIWE fields: ${changedFields.join(", ")}`
18457
+ );
18458
+ }
18459
+ let originalCaps;
18460
+ let signedCaps;
18461
+ try {
18462
+ originalCaps = (0, import_sdk_core2.extractRecapAttenuations)(prepared.siwe);
18463
+ } catch (err) {
18464
+ throw new Error(
18465
+ `Failed to decode prepared SIWE recap: ${err.message}`
18466
+ );
18467
+ }
18468
+ try {
18469
+ signedCaps = (0, import_sdk_core2.extractRecapAttenuations)(result.signedMessage);
18470
+ } catch (err) {
18471
+ throw new Error(
18472
+ `Failed to decode OpenKey signedMessage recap: ${err.message}`
18473
+ );
18474
+ }
18475
+ const unauthorized = (0, import_sdk_core2.unauthorizedRecapCapabilities)(signedCaps, originalCaps);
18476
+ if (unauthorized.length > 0) {
18477
+ const previewLimit = 5;
18478
+ const preview = unauthorized.slice(0, previewLimit).map((u) => `${u.resource}::${u.action}`).join(", ");
18479
+ const overflow = unauthorized.length > previewLimit ? ` (and ${unauthorized.length - previewLimit} more)` : "";
18480
+ throw new Error(
18481
+ `OpenKey signedMessage broadened authorization beyond the original request: ${preview}${overflow}`
18482
+ );
18483
+ }
18484
+ if (!result.signedMessage.includes(prepared.spaceId)) {
18485
+ throw new Error(
18486
+ `OpenKey signedMessage does not reference the expected spaceId ${prepared.spaceId}`
18487
+ );
18488
+ }
18489
+ const grantedPairs = /* @__PURE__ */ new Set();
18490
+ const grantedFourPartIndex = /* @__PURE__ */ new Map();
18491
+ for (const [resource, actions] of Object.entries(signedCaps)) {
18492
+ const { space, path } = (0, import_sdk_core2.parseCanonicalRecapResource)(resource);
18493
+ for (const action of Object.keys(actions)) {
18494
+ grantedPairs.add(`${resource}\0${action}`);
18495
+ const slashIdx = action.indexOf("/");
18496
+ const service = slashIdx > 0 ? action.slice(0, slashIdx) : "";
18497
+ if (service) {
18498
+ grantedFourPartIndex.set(
18499
+ `${service}\0${space}\0${path}\0${action}`,
18500
+ `${resource}\0${action}`
18501
+ );
18502
+ }
18503
+ }
18504
+ }
18505
+ const isStructurallyRequired = (action) => action === "tinycloud.capabilities/read" || action === "capabilities/read";
18506
+ const nonRequiredGrantedPairs = /* @__PURE__ */ new Set();
18507
+ for (const pair of grantedPairs) {
18508
+ const idx = pair.indexOf("\0");
18509
+ const action = idx >= 0 ? pair.slice(idx + 1) : pair;
18510
+ if (!isStructurallyRequired(action)) {
18511
+ nonRequiredGrantedPairs.add(pair);
18512
+ }
18513
+ }
18514
+ const allSignedGrantedPairs = new Set(grantedPairs);
18515
+ const seenRawKeys = /* @__PURE__ */ new Set();
18516
+ for (const rawKey of result.selectedActionKeys) {
18517
+ if (seenRawKeys.has(rawKey)) {
18518
+ throw new Error(
18519
+ `OpenKey selectedActionKeys contains duplicate entry ${rawKey}`
18520
+ );
18521
+ }
18522
+ seenRawKeys.add(rawKey);
18523
+ }
18524
+ const selectedResourceActionPairs = /* @__PURE__ */ new Set();
18525
+ for (const rawKey of result.selectedActionKeys) {
18526
+ const parts = rawKey.split("\0");
18527
+ if (parts.length !== 4) {
18528
+ throw new Error(
18529
+ `OpenKey selectedActionKeys entry is malformed (expected canonical 4-part service\\0space\\0path\\0ability): ${rawKey}`
18530
+ );
18531
+ }
18532
+ const canonical = grantedFourPartIndex.get(rawKey);
18533
+ if (!canonical) {
18534
+ throw new Error(
18535
+ `OpenKey selectedActionKeys entry ${rawKey} is not covered by any granted capability`
18536
+ );
18537
+ }
18538
+ selectedResourceActionPairs.add(canonical);
18539
+ }
18540
+ if (nonRequiredGrantedPairs.size > 0) {
18541
+ const missingPairs = [];
18542
+ for (const pair of nonRequiredGrantedPairs) {
18543
+ if (!selectedResourceActionPairs.has(pair)) {
18544
+ missingPairs.push(pair);
18545
+ }
18546
+ }
18547
+ if (missingPairs.length > 0) {
18548
+ const previewLimit = 5;
18549
+ const preview = missingPairs.slice(0, previewLimit).map((p) => p.replace("\0", "::")).join(", ");
18550
+ const overflow = missingPairs.length > previewLimit ? ` (and ${missingPairs.length - previewLimit} more)` : "";
18551
+ throw new Error(
18552
+ `OpenKey selectedActionKeys is missing entries for signedMessage capabilities: ${preview}${overflow}`
18553
+ );
18554
+ }
18555
+ }
18556
+ if (allSignedGrantedPairs.size > 0 && result.permissions.length === 0) {
18557
+ throw new Error(
18558
+ "OpenKey permissions is empty but signedMessage carries capabilities \u2014 refusing to accept a session without an authoritative grant list"
18559
+ );
18560
+ }
18561
+ const permissionCanonicalKeys = /* @__PURE__ */ new Set();
18562
+ const permissionPairsCovered = /* @__PURE__ */ new Set();
18563
+ for (const perm of result.permissions) {
18564
+ const { service, space, path, actions } = perm;
18565
+ if (typeof service !== "string" || typeof space !== "string" || typeof path !== "string") {
18566
+ throw new Error(
18567
+ `OpenKey permissions entry has non-string service/space/path (service=${service}, space=${space}, path=${path})`
18568
+ );
18569
+ }
18570
+ if (!Array.isArray(actions) || actions.length === 0) {
18571
+ throw new Error(
18572
+ `OpenKey permissions entry (${service} ${space} ${path}) has empty actions \u2014 a capability-bearing entry must list at least one action`
18573
+ );
18574
+ }
18575
+ const canonicalKey = `${service}\0${space}\0${path}`;
18576
+ if (permissionCanonicalKeys.has(canonicalKey)) {
18577
+ throw new Error(
18578
+ `OpenKey permissions has duplicate entry for (${service} ${space} ${path})`
18579
+ );
18580
+ }
18581
+ permissionCanonicalKeys.add(canonicalKey);
18582
+ let expectedResource;
18583
+ if (space.startsWith("tinycloud:")) {
18584
+ const shortService = service.startsWith("tinycloud.") ? service.slice("tinycloud.".length) : service;
18585
+ expectedResource = path ? `${space}/${shortService}/${path}` : `${space}/${shortService}`;
18586
+ } else {
18587
+ if (path) {
18588
+ throw new Error(
18589
+ `OpenKey permissions entry (${service} ${space} ${path}) carries a path on a non-tinycloud: resource URI, which is not a recognized encoding`
18590
+ );
18591
+ }
18592
+ expectedResource = space;
18593
+ }
18594
+ let matchedResource;
18595
+ if (signedCaps[expectedResource] !== void 0) {
18596
+ matchedResource = expectedResource;
18597
+ } else {
18598
+ throw new Error(
18599
+ `OpenKey permissions entry (${service} ${space} ${path}) claims grants not confirmed in signedMessage capabilities`
18600
+ );
18601
+ }
18602
+ const grantedActionsForResource = new Set(
18603
+ Object.keys(signedCaps[matchedResource])
18604
+ );
18605
+ for (const action of actions) {
18606
+ if (typeof action !== "string" || !action) {
18607
+ throw new Error(
18608
+ `OpenKey permissions entry (${service} ${space} ${path}) has non-string action ${String(action)}`
18609
+ );
18610
+ }
18611
+ if (!grantedActionsForResource.has(action)) {
18612
+ throw new Error(
18613
+ `OpenKey permissions entry (${service} ${space} ${path}) claims action ${action} not present in signedMessage capabilities`
18614
+ );
18615
+ }
18616
+ permissionPairsCovered.add(`${matchedResource}\0${action}`);
18617
+ }
18618
+ }
18619
+ if (allSignedGrantedPairs.size > 0) {
18620
+ const missingFromPerms = [];
18621
+ for (const pair of allSignedGrantedPairs) {
18622
+ if (!permissionPairsCovered.has(pair)) {
18623
+ missingFromPerms.push(pair);
18624
+ }
18625
+ }
18626
+ if (missingFromPerms.length > 0) {
18627
+ const previewLimit = 5;
18628
+ const preview = missingFromPerms.slice(0, previewLimit).map((p) => p.replace("\0", "::")).join(", ");
18629
+ const overflow = missingFromPerms.length > previewLimit ? ` (and ${missingFromPerms.length - previewLimit} more)` : "";
18630
+ throw new Error(
18631
+ `OpenKey permissions is missing entries for signedMessage capabilities: ${preview}${overflow}`
18632
+ );
18633
+ }
18634
+ const extrasInPerms = [];
18635
+ for (const pair of permissionPairsCovered) {
18636
+ if (!allSignedGrantedPairs.has(pair)) {
18637
+ extrasInPerms.push(pair);
18638
+ }
18639
+ }
18640
+ if (extrasInPerms.length > 0) {
18641
+ const previewLimit = 5;
18642
+ const preview = extrasInPerms.slice(0, previewLimit).map((p) => p.replace("\0", "::")).join(", ");
18643
+ const overflow = extrasInPerms.length > previewLimit ? ` (and ${extrasInPerms.length - previewLimit} more)` : "";
18644
+ throw new Error(
18645
+ `OpenKey permissions contains entries not present in the signed authority: ${preview}${overflow}`
18646
+ );
18647
+ }
18648
+ }
18649
+ void nonRequiredGrantedPairs;
18650
+ return this.signInWithPreparedSession(
18651
+ {
18652
+ siwe: result.signedMessage,
18653
+ jwk: prepared.jwk,
18654
+ spaceId: prepared.spaceId,
18655
+ verificationMethod: prepared.verificationMethod
18656
+ },
18657
+ result.signature,
18658
+ keyId,
18659
+ jwk
18660
+ );
18661
+ }
18233
18662
  /**
18234
18663
  * Clear persisted session data.
18235
18664
  */
@@ -19030,7 +19459,9 @@ function base64UrlDecode(value) {
19030
19459
  return new Uint8Array(bytes);
19031
19460
  }
19032
19461
  async function signJwtInputWithJwk(signingInput, jwk) {
19033
- const bytes = new TextEncoder().encode(signingInput);
19462
+ return signBytesWithJwk(new TextEncoder().encode(signingInput), jwk);
19463
+ }
19464
+ async function signBytesWithJwk(bytes, jwk) {
19034
19465
  try {
19035
19466
  const subtle = globalThis.crypto?.subtle;
19036
19467
  if (!subtle) {
@@ -19111,6 +19542,12 @@ var _TinyCloudNode = class _TinyCloudNode {
19111
19542
  this._baseSecrets = /* @__PURE__ */ new Map();
19112
19543
  this._secrets = /* @__PURE__ */ new Map();
19113
19544
  this.runtimePermissionGrants = [];
19545
+ /**
19546
+ * Owned space ids this sign-in has already confirmed are hosted. Consulted by
19547
+ * {@link ensureOwnedSpaceHostedById}; cleared on every {@link signIn} because
19548
+ * hosting is confirmed against the session that was active at the time.
19549
+ */
19550
+ this.confirmedHostedSpaceIds = /* @__PURE__ */ new Set();
19114
19551
  /**
19115
19552
  * True when the last signIn() detected an interactive signer and skipped
19116
19553
  * client-side bootstrap. Apps can read this to know whether bootstrap was
@@ -19121,7 +19558,10 @@ var _TinyCloudNode = class _TinyCloudNode {
19121
19558
  * Outcome of the last signIn()'s account-bootstrap attempt. `skipped` is
19122
19559
  * true when bootstrap did not complete (interactive signer, auto-sign
19123
19560
  * denied, or a bootstrap step failed); `reason` carries the cause so apps
19124
- * can surface a "finish account setup" call-to-action.
19561
+ * can surface a "finish account setup" call-to-action. `warnings` is
19562
+ * present when bootstrap completed, but one or more steps recovered from
19563
+ * an ambiguous failure (e.g. a KV batch write reconciled via per-space
19564
+ * fallback) — clean vs. recovered runs are otherwise byte-identical.
19125
19565
  */
19126
19566
  this._bootstrapStatus = {
19127
19567
  skipped: false
@@ -19507,6 +19947,7 @@ var _TinyCloudNode = class _TinyCloudNode {
19507
19947
  this._spaceService = void 0;
19508
19948
  this._serviceContext = void 0;
19509
19949
  this.runtimePermissionGrants = [];
19950
+ this.confirmedHostedSpaceIds.clear();
19510
19951
  await this.tc.signIn(options);
19511
19952
  this.syncResolvedHostFromAuth();
19512
19953
  const oldGraph = this._serviceGraph;
@@ -19561,7 +20002,13 @@ var _TinyCloudNode = class _TinyCloudNode {
19561
20002
  return false;
19562
20003
  }
19563
20004
  try {
19564
- await this.runAccountBootstrap(steps);
20005
+ const warnings = await this.runAccountBootstrap(steps);
20006
+ if (warnings.length > 0) {
20007
+ this._bootstrapStatus = { skipped: false, warnings };
20008
+ this.notificationHandler.warning(
20009
+ `Account bootstrap completed with warnings: ${warnings.map((w) => `${w.stepId} (${w.kind}: ${w.code})`).join("; ")}`
20010
+ );
20011
+ }
19565
20012
  } catch (err) {
19566
20013
  const reason = err instanceof Error ? err.message : String(err);
19567
20014
  this._bootstrapSkipped = true;
@@ -19610,6 +20057,7 @@ var _TinyCloudNode = class _TinyCloudNode {
19610
20057
  const auth = this.auth;
19611
20058
  const sessions = /* @__PURE__ */ new Map();
19612
20059
  const rawAbilitiesBySpace = /* @__PURE__ */ new Map();
20060
+ const warnings = [];
19613
20061
  const primarySession = auth.tinyCloudSession;
19614
20062
  const defaultSpaceId = this.ownedSpaceId("default");
19615
20063
  const canReusePrimaryBootstrapSession = primarySession?.spaceId === defaultSpaceId && auth.capabilityRequest === import_sdk_core7.BOOTSTRAP_SESSION_REQUESTS.default;
@@ -19671,32 +20119,45 @@ var _TinyCloudNode = class _TinyCloudNode {
19671
20119
  }
19672
20120
  }
19673
20121
  if (step.kind === "seed-spaces") {
19674
- for (const space of step.spaces) {
19675
- const registered = await this.account.spaces.register({
20122
+ const registered = await this.account.spaces.registerBatch(
20123
+ step.spaces.map((space) => ({
19676
20124
  spaceId: space.spaceId,
19677
20125
  name: space.name,
19678
20126
  ownerDid: this.did,
19679
20127
  type: "owned",
19680
20128
  permissions: ["*"],
19681
20129
  status: "active"
20130
+ }))
20131
+ );
20132
+ if (!registered.ok) {
20133
+ throw new Error(
20134
+ `Failed to seed account spaces: ${registered.error.message}`
20135
+ );
20136
+ }
20137
+ const batchError = registered.data.recoveredFromBatchError;
20138
+ if (batchError) {
20139
+ warnings.push({
20140
+ stepId: step.id,
20141
+ kind: "batch-write-reconciled",
20142
+ code: batchError.code,
20143
+ message: batchError.message
19682
20144
  });
19683
- if (!registered.ok) {
19684
- throw new Error(
19685
- `Failed to seed account space ${space.spaceId}: ${registered.error.message}`
19686
- );
19687
- }
19688
20145
  }
19689
20146
  }
19690
20147
  if (step.kind === "seed-applications") {
19691
20148
  const registered = await this.account.applications.register(
19692
- step.manifests.length > 0 ? [...step.manifests] : import_sdk_core7.TINYCLOUD_SECRETS_BOOTSTRAP_MANIFEST
20149
+ step.manifests.length > 0 ? [...step.manifests] : import_sdk_core7.TINYCLOUD_SECRETS_BOOTSTRAP_MANIFEST,
20150
+ // Bootstrap only runs on an account with no registry records, so the
20151
+ // manifest-hash pre-read is definitionally a miss. Skip it; the write
20152
+ // it guards is an INSERT OR REPLACE and is safe to repeat.
20153
+ { assumeUnregistered: true }
19693
20154
  );
19694
20155
  if (!registered.ok) {
19695
20156
  throw new Error(`Failed to seed bootstrap applications: ${registered.error.message}`);
19696
20157
  }
19697
20158
  }
19698
20159
  if (step.kind === "encryption-network-create") {
19699
- await this.ensureEncryptionNetwork(step.networkId);
20160
+ await this.ensureEncryptionNetwork(step.networkId, { assumeMissing: true });
19700
20161
  }
19701
20162
  if (step.kind === "secret-records-schema") {
19702
20163
  const db = this.sqlForSpace(step.spaceId).db(step.database);
@@ -19716,6 +20177,7 @@ var _TinyCloudNode = class _TinyCloudNode {
19716
20177
  }
19717
20178
  }
19718
20179
  }
20180
+ return warnings;
19719
20181
  }
19720
20182
  registerBootstrapRuntimeGrant(session, request, rawAbilities) {
19721
20183
  const operations = [];
@@ -19975,6 +20437,22 @@ var _TinyCloudNode = class _TinyCloudNode {
19975
20437
  await this.ensureEncryptionNetwork(networkId);
19976
20438
  }
19977
20439
  }
20440
+ /**
20441
+ * Ensure one of this user's owned spaces is hosted, at most once per space
20442
+ * per sign-in.
20443
+ *
20444
+ * Account bootstrap funnels six calls through here (via
20445
+ * `AccountService.ensureAccountSpaceHosted`), and each one re-submits
20446
+ * `activateSessionWithHost` with the *primary* session. That session's recap
20447
+ * covers only `default` and `secrets`, so the account space appears in
20448
+ * neither `activated` nor `skipped` and the guard below returns immediately
20449
+ * — the repeat calls cannot change the outcome, they only cost round trips.
20450
+ *
20451
+ * The memo is per sign-in ({@link confirmedHostedSpaceIds} is cleared in
20452
+ * `signIn`) and only records confirmed successes. A wrong skip is
20453
+ * self-revealing: the next write to the space fails immediately and loudly
20454
+ * with `404 Space not found`.
20455
+ */
19978
20456
  async ensureOwnedSpaceHostedById(spaceId) {
19979
20457
  if (!this.auth) {
19980
20458
  throw new Error("Owned space hosting requires wallet mode");
@@ -19983,12 +20461,16 @@ var _TinyCloudNode = class _TinyCloudNode {
19983
20461
  if (!session) {
19984
20462
  throw new Error("Owned space hosting requires an active session");
19985
20463
  }
20464
+ if (this.confirmedHostedSpaceIds.has(spaceId)) {
20465
+ return;
20466
+ }
19986
20467
  const host = this.hosts[0] ?? this.config.host;
19987
20468
  if (!host) {
19988
20469
  throw new Error("Owned space hosting requires a TinyCloud host");
19989
20470
  }
19990
20471
  const activation = await (0, import_sdk_core7.activateSessionWithHost)(host, session.delegationHeader);
19991
20472
  if (activation.success && !activation.skipped?.includes(spaceId)) {
20473
+ this.confirmedHostedSpaceIds.add(spaceId);
19992
20474
  return;
19993
20475
  }
19994
20476
  if (!activation.success && activation.status !== 404) {
@@ -20007,6 +20489,7 @@ var _TinyCloudNode = class _TinyCloudNode {
20007
20489
  `Failed to activate session after creating owned space ${spaceId}: ${retry.error ?? "space was skipped"}`
20008
20490
  );
20009
20491
  }
20492
+ this.confirmedHostedSpaceIds.add(spaceId);
20010
20493
  }
20011
20494
  /**
20012
20495
  * Host one of this user's owned spaces by name (e.g. `"applications"`).
@@ -20322,6 +20805,7 @@ var _TinyCloudNode = class _TinyCloudNode {
20322
20805
  this._delegationManager = stagedGraph.delegationManager;
20323
20806
  this._spaceService = stagedGraph.spaceService;
20324
20807
  this._serviceGraph = stagedGraph.graph;
20808
+ this._activeServiceSession = serviceSession;
20325
20809
  this._baseSecrets = /* @__PURE__ */ new Map();
20326
20810
  this._secrets = /* @__PURE__ */ new Map();
20327
20811
  this._publicKV = void 0;
@@ -20329,6 +20813,7 @@ var _TinyCloudNode = class _TinyCloudNode {
20329
20813
  this.tc = stagedGraph.core;
20330
20814
  this._recapOperationsCache = stagedPrimary.cache;
20331
20815
  this.runtimePermissionGrants = stagedPrimary.grants;
20816
+ this.confirmedHostedSpaceIds.clear();
20332
20817
  if (this.auth) {
20333
20818
  this.auth.installRestoredSession(stagedManager, stagedTcSession, [stagedHost]);
20334
20819
  this._restoredTcSession = void 0;
@@ -20638,6 +21123,28 @@ var _TinyCloudNode = class _TinyCloudNode {
20638
21123
  currentTinyCloudSession() {
20639
21124
  return this.auth?.tinyCloudSession ?? this._restoredTcSession;
20640
21125
  }
21126
+ /**
21127
+ * Mint a multi-capability invocation through the established session key.
21128
+ * Callers receive only the signed headers; session key material remains
21129
+ * owned by the session manager and is never part of the caller contract.
21130
+ */
21131
+ invokeAny(entries, facts) {
21132
+ const session = this.currentTinyCloudSession() ?? this._activeServiceSession;
21133
+ if (session === void 0) throw new Error("Not signed in. Call signIn() first.");
21134
+ return this._serviceGraph.invokeAny(session, entries, facts);
21135
+ }
21136
+ /** Sign protocol bytes with the established in-memory session key. */
21137
+ async signSessionBytes(bytes) {
21138
+ const session = this.currentTinyCloudSession() ?? this._activeServiceSession;
21139
+ if (session === void 0) throw new Error("Not signed in. Call signIn() first.");
21140
+ return signBytesWithJwk(bytes, session.jwk);
21141
+ }
21142
+ /** Bind a session-signed invocation to the canonical service audience. */
21143
+ async bindInvocationAudience(authorization, audience) {
21144
+ const session = this.currentTinyCloudSession() ?? this._activeServiceSession;
21145
+ if (session === void 0) throw new Error("Not signed in. Call signIn() first.");
21146
+ return rewriteInvocationAudience(authorization, audience, session.jwk);
21147
+ }
20641
21148
  /**
20642
21149
  * Connect a wallet to upgrade from session-only mode to wallet mode.
20643
21150
  *
@@ -20819,6 +21326,7 @@ var _TinyCloudNode = class _TinyCloudNode {
20819
21326
  if (this._serviceContext) {
20820
21327
  const spaceScopedContext = this._serviceGraph.track(new import_sdk_core7.ServiceContext({
20821
21328
  invoke: this._serviceContext.invoke,
21329
+ invokeAny: this._serviceContext.invokeAny,
20822
21330
  fetch: this._serviceContext.fetch,
20823
21331
  hosts: this._serviceContext.hosts,
20824
21332
  telemetry: this.config.telemetry
@@ -20911,7 +21419,17 @@ var _TinyCloudNode = class _TinyCloudNode {
20911
21419
  verifyNodeSignature: (nodeId, message, signature2) => (0, import_sdk_core7.verifyDidKeyEd25519Signature)(nodeId, message, signature2)
20912
21420
  };
20913
21421
  }
21422
+ /**
21423
+ * The node DID for {@link config.host}.
21424
+ *
21425
+ * Sign-in already performs `GET /info` for the protocol-version check, and
21426
+ * that response carries `nodeId`, so reuse it rather than fetching `/info` a
21427
+ * second time. Only a value recorded for this exact host qualifies; older
21428
+ * nodes that omit `nodeId` fall through to the fetch.
21429
+ */
20914
21430
  async fetchNodeId() {
21431
+ const cached = this.auth?.nodeIdForHost(this.config.host);
21432
+ if (cached) return cached;
20915
21433
  const response = await fetch(`${this.config.host}/info`);
20916
21434
  if (!response.ok) {
20917
21435
  throw new Error(`Failed to fetch node info: HTTP ${response.status}`);
@@ -21474,6 +21992,7 @@ var _TinyCloudNode = class _TinyCloudNode {
21474
21992
  shareUrl: input.shareUrl,
21475
21993
  documentName: input.documentName,
21476
21994
  jti: base64UrlEncode(crypto.getRandomValues(new Uint8Array(16))),
21995
+ idempotencyKey: input.idempotencyKey,
21477
21996
  expiresAt: input.expiresAt
21478
21997
  };
21479
21998
  const requestBodyDigest = base64UrlEncode(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode((0, import_sdk_core7.canonicalizeEncryptionJson)(body)))));
@@ -21683,6 +22202,13 @@ var _TinyCloudNode = class _TinyCloudNode {
21683
22202
  body
21684
22203
  )
21685
22204
  });
22205
+ if (response.status === 409) {
22206
+ const existing = await this.getEncryptionNetwork(networkId);
22207
+ if (existing) return existing;
22208
+ throw new Error(
22209
+ `Encryption network ${networkId} already exists but could not be read back`
22210
+ );
22211
+ }
21686
22212
  if (!response.ok) {
21687
22213
  throw new Error(
21688
22214
  `Failed to create encryption network ${networkId}: HTTP ${response.status} ${await response.text()}`
@@ -21691,11 +22217,19 @@ var _TinyCloudNode = class _TinyCloudNode {
21691
22217
  const created = await response.json();
21692
22218
  return created.descriptor;
21693
22219
  }
21694
- async ensureEncryptionNetwork(nameOrNetworkId = DEFAULT_ENCRYPTION_NETWORK_NAME) {
22220
+ /**
22221
+ * Ensure an encryption network exists, creating it if necessary.
22222
+ *
22223
+ * @param nameOrNetworkId - Network name or full `urn:tinycloud:encryption:` id.
22224
+ * @param options - See {@link EnsureEncryptionNetworkOptions}.
22225
+ */
22226
+ async ensureEncryptionNetwork(nameOrNetworkId = DEFAULT_ENCRYPTION_NETWORK_NAME, options = {}) {
21695
22227
  const networkId = nameOrNetworkId.startsWith("urn:tinycloud:encryption:") ? nameOrNetworkId : this.getDefaultEncryptionNetworkId(nameOrNetworkId);
21696
- const existing = await this.getEncryptionNetwork(networkId);
21697
- if (existing) {
21698
- return existing;
22228
+ if (!options.assumeMissing) {
22229
+ const existing = await this.getEncryptionNetwork(networkId);
22230
+ if (existing) {
22231
+ return existing;
22232
+ }
21699
22233
  }
21700
22234
  const parsed = (0, import_sdk_core7.parseNetworkId)(networkId);
21701
22235
  if (!didPrincipalMatches2(parsed.ownerDid, this.did)) {
@@ -23686,6 +24220,50 @@ var FileSessionStorage = class {
23686
24220
  }
23687
24221
  };
23688
24222
 
24223
+ // src/authorization/openKeyBridge.ts
24224
+ function wireOpenKeyAuthorize(openkey) {
24225
+ return async (input) => {
24226
+ const result = await openkey.authorizeTinyCloud({
24227
+ protocolVersion: 1,
24228
+ siwe: input.siwe,
24229
+ keyId: input.keyId,
24230
+ jwk: input.jwk,
24231
+ host: input.host,
24232
+ // Forward the presentation envelope verbatim — the OpenKey SDK
24233
+ // validates and size-bounds it before use.
24234
+ presentation: input.presentation
24235
+ });
24236
+ if (result.protocolVersion !== 1) {
24237
+ throw new Error(
24238
+ `wireOpenKeyAuthorize: OpenKey returned unsupported protocolVersion ${result.protocolVersion}`
24239
+ );
24240
+ }
24241
+ if (typeof result.signedMessage !== "string" || !result.signedMessage) {
24242
+ throw new Error(
24243
+ "wireOpenKeyAuthorize: OpenKey returned no signedMessage \u2014 cannot complete a TinyCloud session"
24244
+ );
24245
+ }
24246
+ if (!Array.isArray(result.selectedActionKeys)) {
24247
+ throw new Error(
24248
+ "wireOpenKeyAuthorize: OpenKey returned no selectedActionKeys"
24249
+ );
24250
+ }
24251
+ if (!Array.isArray(result.permissions)) {
24252
+ throw new Error(
24253
+ "wireOpenKeyAuthorize: OpenKey returned no permissions"
24254
+ );
24255
+ }
24256
+ return {
24257
+ protocolVersion: 1,
24258
+ address: result.address,
24259
+ signature: result.signature,
24260
+ signedMessage: result.signedMessage,
24261
+ selectedActionKeys: result.selectedActionKeys,
24262
+ permissions: result.permissions
24263
+ };
24264
+ };
24265
+ }
24266
+
23689
24267
  // src/index.ts
23690
24268
  var import_sdk_core12 = require("@tinycloud/sdk-core");
23691
24269
 
@@ -24159,7 +24737,8 @@ var import_sdk_core23 = require("@tinycloud/sdk-core");
24159
24737
  tinyCloudDebugLogger,
24160
24738
  validateEnvelope,
24161
24739
  validateManifest,
24162
- verifyDecryptResponse
24740
+ verifyDecryptResponse,
24741
+ wireOpenKeyAuthorize
24163
24742
  });
24164
24743
  /*! Bundled license information:
24165
24744