@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.js CHANGED
@@ -17218,6 +17218,8 @@ import {
17218
17218
  } from "@tinycloud/sdk-services";
17219
17219
 
17220
17220
  // src/authorization/NodeUserAuthorization.ts
17221
+ import { createHash } from "crypto";
17222
+ import { SiweMessage } from "siwe";
17221
17223
  import {
17222
17224
  fetchPeerId,
17223
17225
  submitHostDelegation,
@@ -17236,6 +17238,11 @@ import {
17236
17238
  canonicalizeAddress,
17237
17239
  makePkhSpaceId,
17238
17240
  pkhDid,
17241
+ extractImmutableSiweFields,
17242
+ diffImmutableSiweFields,
17243
+ extractRecapAttenuations,
17244
+ unauthorizedRecapCapabilities,
17245
+ parseCanonicalRecapResource,
17239
17246
  KV,
17240
17247
  SQL,
17241
17248
  DUCKDB,
@@ -17322,6 +17329,27 @@ var MemorySessionStorage = class {
17322
17329
  // src/authorization/NodeUserAuthorization.ts
17323
17330
  var DECRYPT_ACTION = ENCRYPTION.DECRYPT;
17324
17331
  var NETWORK_CREATE_ACTION = ENCRYPTION.NETWORK_CREATE;
17332
+ function canonicalizeOpenKeyManifestJson(value) {
17333
+ const json = JSON.stringify(value);
17334
+ if (json === void 0) {
17335
+ throw new TypeError("manifest is not JSON-serializable");
17336
+ }
17337
+ return canonicalizeJsonValue(JSON.parse(json));
17338
+ }
17339
+ function canonicalizeJsonValue(value) {
17340
+ if (value === null || typeof value !== "object") {
17341
+ return JSON.stringify(value);
17342
+ }
17343
+ if (Array.isArray(value)) {
17344
+ return `[${value.map(canonicalizeJsonValue).join(",")}]`;
17345
+ }
17346
+ return `{${Object.keys(value).sort().map(
17347
+ (key2) => `${JSON.stringify(key2)}:${canonicalizeJsonValue(value[key2])}`
17348
+ ).join(",")}}`;
17349
+ }
17350
+ function canonicalOpenKeyManifestSha256Hex(value) {
17351
+ return createHash("sha256").update(canonicalizeOpenKeyManifestJson(value)).digest("hex");
17352
+ }
17325
17353
  function didPrincipalMatches(actual, expected) {
17326
17354
  try {
17327
17355
  return principalDidEquals(actual, expected);
@@ -17542,6 +17570,15 @@ var NodeUserAuthorization = class {
17542
17570
  get nodeFeatures() {
17543
17571
  return this._nodeFeatures;
17544
17572
  }
17573
+ /**
17574
+ * The node DID reported by `GET /info` for `host`, if the sign-in (or
17575
+ * restore) that populated it targeted that same host. Returns `undefined`
17576
+ * for any other host, and for nodes whose `/info` omits `nodeId`, so callers
17577
+ * fall back to fetching it themselves rather than using a foreign node's DID.
17578
+ */
17579
+ nodeIdForHost(host) {
17580
+ return this._nodeInfoIdentity?.host === host ? this._nodeInfoIdentity.nodeId : void 0;
17581
+ }
17545
17582
  get lastActivationSkippedSpaceIds() {
17546
17583
  return [...this._lastActivationSkippedSpaceIds];
17547
17584
  }
@@ -18011,11 +18048,13 @@ var NodeUserAuthorization = class {
18011
18048
  this._tinyCloudSession = tinyCloudSession;
18012
18049
  this._address = address;
18013
18050
  this._chainId = chainId;
18051
+ const infoHost = this.primaryTinyCloudHost;
18014
18052
  const nodeInfo = await checkNodeInfo(
18015
- this.primaryTinyCloudHost,
18053
+ infoHost,
18016
18054
  this.wasm.protocolVersion()
18017
18055
  );
18018
18056
  this._nodeFeatures = nodeInfo.features;
18057
+ this._nodeInfoIdentity = nodeInfo.nodeId ? { host: infoHost, nodeId: nodeInfo.nodeId } : void 0;
18019
18058
  for (const ext of this.extensions) {
18020
18059
  if (ext.afterSignIn) {
18021
18060
  await ext.afterSignIn(clientSession);
@@ -18181,11 +18220,13 @@ var NodeUserAuthorization = class {
18181
18220
  this._tinyCloudSession = tinyCloudSession;
18182
18221
  this._address = address;
18183
18222
  this._chainId = chainId;
18223
+ const infoHost = this.primaryTinyCloudHost;
18184
18224
  const nodeInfo = await checkNodeInfo(
18185
- this.primaryTinyCloudHost,
18225
+ infoHost,
18186
18226
  this.wasm.protocolVersion()
18187
18227
  );
18188
18228
  this._nodeFeatures = nodeInfo.features;
18229
+ this._nodeInfoIdentity = nodeInfo.nodeId ? { host: infoHost, nodeId: nodeInfo.nodeId } : void 0;
18189
18230
  for (const ext of this.extensions) {
18190
18231
  if (ext.afterSignIn) {
18191
18232
  await ext.afterSignIn(clientSession);
@@ -18194,6 +18235,398 @@ var NodeUserAuthorization = class {
18194
18235
  await this.ensureSpaceExists();
18195
18236
  return clientSession;
18196
18237
  }
18238
+ /**
18239
+ * End-to-end sign-in via OpenKey `authorizeTinyCloud()` (Sol CRITICAL-4).
18240
+ *
18241
+ * This is the production entry point that wires
18242
+ * {@link prepareSessionForSigning} → OpenKey `authorizeTinyCloud()` →
18243
+ * {@link signInWithOpenKeyResult} into one call. Consumers pass in a
18244
+ * caller-provided `authorizeFn` (typically a thin wrapper around
18245
+ * `openkey.authorizeTinyCloud()`) so this method has no direct
18246
+ * dependency on the OpenKey SDK — it works with any transport that
18247
+ * conforms to the versioned protocol.
18248
+ *
18249
+ * The `authorizeFn` MUST:
18250
+ * 1. Send the prepared SIWE bytes to OpenKey.
18251
+ * 2. Return the exact `TinyCloudAuthorizationResultV1` shape (with
18252
+ * `signedMessage` — the bytes the signature actually verifies
18253
+ * against — plus `selectedActionKeys` and `permissions`).
18254
+ *
18255
+ * NodeUserAuthorization then completes the session with `signedMessage`
18256
+ * (never the prepared bytes), verifying the signature, the immutable
18257
+ * SIWE fields, and every subset invariant enforced by
18258
+ * {@link signInWithOpenKeyResult}. This preserves the exact-byte
18259
+ * guarantee: if the user did NOT narrow, the returned `signedMessage`
18260
+ * equals the prepared SIWE byte-for-byte; if the user narrowed, only
18261
+ * the ReCap block differs and every immutable header field survives.
18262
+ *
18263
+ * Legacy exact-byte {@link signIn} callers are unaffected — this method
18264
+ * is opt-in.
18265
+ *
18266
+ * @param authorizeFn Callback that invokes the versioned OpenKey
18267
+ * `authorizeTinyCloud()` protocol and returns the result. Receives
18268
+ * the prepared SIWE bytes plus a `host` hint the widget should bind
18269
+ * into its /authorize-sign-prepare context.
18270
+ * @param options Optional overrides for the OpenKey request.
18271
+ */
18272
+ async signInWithOpenKey(authorizeFn, options) {
18273
+ if (options?.host !== void 0) {
18274
+ const overrideHost = options.host.trim();
18275
+ if (!overrideHost) {
18276
+ throw new Error("OpenKey authorization host must not be empty");
18277
+ }
18278
+ this.tinycloudHosts = [overrideHost];
18279
+ }
18280
+ const signingAddress = canonicalizeAddress(await this.signer.getAddress());
18281
+ const signingChainId = await this.signer.getChainId();
18282
+ await this.resolveTinyCloudHostsForSignIn(signingAddress, signingChainId);
18283
+ const { prepared, keyId, jwk } = await this.prepareSessionForSigning();
18284
+ const presentation = this.buildPresentationEnvelope(options?.reason);
18285
+ const hostHint = this.primaryTinyCloudHost;
18286
+ const result = await authorizeFn({
18287
+ protocolVersion: 1,
18288
+ siwe: prepared.siwe,
18289
+ jwk,
18290
+ host: hostHint,
18291
+ keyId: options?.openkeyKeyId,
18292
+ presentation
18293
+ });
18294
+ return this.signInWithOpenKeyResult(result, prepared, keyId, jwk);
18295
+ }
18296
+ /**
18297
+ * Build a `CapabilityPresentationEnvelopeV1` from `this._manifest`. The
18298
+ * envelope is display-only — the OpenKey widget renders it under an
18299
+ * honest trust label, and the server /authorize-sign-prepare route
18300
+ * decides whether to upgrade the label to `origin-bound` by fetching
18301
+ * the well-known manifest and comparing its digest to
18302
+ * `manifestDigest`.
18303
+ *
18304
+ * Returns `undefined` when no manifest is set — the widget renders as
18305
+ * "no manifest supplied" (unsigned).
18306
+ *
18307
+ * The digest is a deterministic SHA-256 over canonical JSON of the
18308
+ * first manifest payload; OpenKey parses the fetched well-known manifest
18309
+ * and applies the same canonicalization before comparing digests.
18310
+ */
18311
+ buildPresentationEnvelope(reason) {
18312
+ const manifests = Array.isArray(this._manifest) ? this._manifest : this._manifest ? [this._manifest] : [];
18313
+ if (manifests.length === 0 && !reason) {
18314
+ return void 0;
18315
+ }
18316
+ const primary = manifests[0];
18317
+ let displayName;
18318
+ let manifestId;
18319
+ let manifestDigest;
18320
+ const envelopeManifests = [];
18321
+ for (const m of manifests) {
18322
+ envelopeManifests.push({
18323
+ name: m.name,
18324
+ appId: m.app_id,
18325
+ // Deep-copy to prevent later mutations from affecting the digest.
18326
+ payload: JSON.parse(JSON.stringify(m))
18327
+ });
18328
+ }
18329
+ if (primary) {
18330
+ displayName = primary.name;
18331
+ manifestId = primary.app_id;
18332
+ manifestDigest = canonicalOpenKeyManifestSha256Hex(primary);
18333
+ }
18334
+ return {
18335
+ protocolVersion: 1,
18336
+ displayName,
18337
+ reason: reason && reason.length > 0 ? reason : void 0,
18338
+ manifestId,
18339
+ manifestDigest,
18340
+ manifests: envelopeManifests.length > 0 ? envelopeManifests : void 0
18341
+ };
18342
+ }
18343
+ /**
18344
+ * Complete sign-in with a versioned OpenKey authorization result.
18345
+ *
18346
+ * Where `signInWithPreparedSession` completes with a signature over the
18347
+ * ORIGINAL prepared SIWE, this method completes with the SIWE bytes the
18348
+ * OpenKey widget actually presented and signed (`signedMessage`). It
18349
+ * exists because the versioned authorizeTinyCloud protocol lets the user
18350
+ * narrow capabilities; the returned bytes may differ from the original
18351
+ * request and are the only ones the signature actually verifies against.
18352
+ *
18353
+ * The `result` parameter is validated at the trust boundary via
18354
+ * `validateAuthorizationResultV1` (in sdk-core). Callers should validate
18355
+ * BEFORE calling this method so unknown/missing fields are rejected
18356
+ * before any session state is created.
18357
+ *
18358
+ * Validation performed here (defence-in-depth: even a "valid" v1 payload
18359
+ * must clear these checks before any session state is created):
18360
+ * 1. `result.address` matches the local signer's address.
18361
+ * 2. `result.signature` recovers to `result.address` when verified against
18362
+ * `result.signedMessage`.
18363
+ * 3. Every immutable SIWE header field (domain, address, URI, version,
18364
+ * chainId, nonce, issuedAt) in the original prepared SIWE is preserved
18365
+ * byte-for-byte in `result.signedMessage`. `statement` is NOT in this
18366
+ * list because the WASM renders it from the ReCap contents; it MUST
18367
+ * change when the ReCap is narrowed.
18368
+ * 4. Every capability in `result.signedMessage`'s ReCap block was present
18369
+ * in the original prepared SIWE — narrowing OK, broadening rejected.
18370
+ * 5. Every `result.selectedActionKeys` entry corresponds to a
18371
+ * (resource, action) pair in the returned SIWE.
18372
+ */
18373
+ async signInWithOpenKeyResult(result, prepared, keyId, jwk) {
18374
+ if (result.protocolVersion !== 1) {
18375
+ throw new Error(`Unsupported OpenKey protocol version ${result.protocolVersion}`);
18376
+ }
18377
+ if (!result.signedMessage) {
18378
+ throw new Error("OpenKey result must include signedMessage");
18379
+ }
18380
+ if (!prepared.siwe) {
18381
+ throw new Error(
18382
+ "signInWithOpenKeyResult requires prepared.siwe (the SDK-generated SIWE the sign-in was proposed with)"
18383
+ );
18384
+ }
18385
+ const expectedAddress = canonicalizeAddress(await this.signer.getAddress());
18386
+ if (canonicalizeAddress(result.address) !== expectedAddress) {
18387
+ throw new Error(
18388
+ `OpenKey returned signature from ${result.address} but expected ${expectedAddress}`
18389
+ );
18390
+ }
18391
+ let parsedSignedSiwe;
18392
+ try {
18393
+ parsedSignedSiwe = new SiweMessage(result.signedMessage);
18394
+ } catch (err) {
18395
+ throw new Error(
18396
+ `OpenKey signedMessage is not a parseable SIWE: ${err.message}`
18397
+ );
18398
+ }
18399
+ const verifyResult = await parsedSignedSiwe.verify(
18400
+ { signature: result.signature },
18401
+ { suppressExceptions: true }
18402
+ );
18403
+ if (!verifyResult.success) {
18404
+ const errData = verifyResult.error;
18405
+ const type = errData && typeof errData === "object" && "type" in errData ? errData.type : void 0;
18406
+ const details = errData && typeof errData === "object" && "expected" in errData ? ` (expected=${errData.expected}, received=${errData.received})` : "";
18407
+ throw new Error(
18408
+ `OpenKey signature verification failed${type ? `: ${type}` : ""}${details}`
18409
+ );
18410
+ }
18411
+ if (canonicalizeAddress(parsedSignedSiwe.address) !== expectedAddress) {
18412
+ throw new Error(
18413
+ `OpenKey signedMessage.address (${parsedSignedSiwe.address}) does not match local signer address (${expectedAddress})`
18414
+ );
18415
+ }
18416
+ const originalHasRecap = /(?:^|\n)-\s+urn:recap:/i.test(prepared.siwe);
18417
+ const originalFields = extractImmutableSiweFields(prepared.siwe);
18418
+ const signedFields = extractImmutableSiweFields(result.signedMessage);
18419
+ const changedFields = diffImmutableSiweFields(originalFields, signedFields, {
18420
+ originalHasRecap
18421
+ });
18422
+ if (changedFields.length > 0) {
18423
+ throw new Error(
18424
+ `OpenKey signedMessage altered immutable SIWE fields: ${changedFields.join(", ")}`
18425
+ );
18426
+ }
18427
+ let originalCaps;
18428
+ let signedCaps;
18429
+ try {
18430
+ originalCaps = extractRecapAttenuations(prepared.siwe);
18431
+ } catch (err) {
18432
+ throw new Error(
18433
+ `Failed to decode prepared SIWE recap: ${err.message}`
18434
+ );
18435
+ }
18436
+ try {
18437
+ signedCaps = extractRecapAttenuations(result.signedMessage);
18438
+ } catch (err) {
18439
+ throw new Error(
18440
+ `Failed to decode OpenKey signedMessage recap: ${err.message}`
18441
+ );
18442
+ }
18443
+ const unauthorized = unauthorizedRecapCapabilities(signedCaps, originalCaps);
18444
+ if (unauthorized.length > 0) {
18445
+ const previewLimit = 5;
18446
+ const preview = unauthorized.slice(0, previewLimit).map((u) => `${u.resource}::${u.action}`).join(", ");
18447
+ const overflow = unauthorized.length > previewLimit ? ` (and ${unauthorized.length - previewLimit} more)` : "";
18448
+ throw new Error(
18449
+ `OpenKey signedMessage broadened authorization beyond the original request: ${preview}${overflow}`
18450
+ );
18451
+ }
18452
+ if (!result.signedMessage.includes(prepared.spaceId)) {
18453
+ throw new Error(
18454
+ `OpenKey signedMessage does not reference the expected spaceId ${prepared.spaceId}`
18455
+ );
18456
+ }
18457
+ const grantedPairs = /* @__PURE__ */ new Set();
18458
+ const grantedFourPartIndex = /* @__PURE__ */ new Map();
18459
+ for (const [resource, actions] of Object.entries(signedCaps)) {
18460
+ const { space, path } = parseCanonicalRecapResource(resource);
18461
+ for (const action of Object.keys(actions)) {
18462
+ grantedPairs.add(`${resource}\0${action}`);
18463
+ const slashIdx = action.indexOf("/");
18464
+ const service = slashIdx > 0 ? action.slice(0, slashIdx) : "";
18465
+ if (service) {
18466
+ grantedFourPartIndex.set(
18467
+ `${service}\0${space}\0${path}\0${action}`,
18468
+ `${resource}\0${action}`
18469
+ );
18470
+ }
18471
+ }
18472
+ }
18473
+ const isStructurallyRequired = (action) => action === "tinycloud.capabilities/read" || action === "capabilities/read";
18474
+ const nonRequiredGrantedPairs = /* @__PURE__ */ new Set();
18475
+ for (const pair of grantedPairs) {
18476
+ const idx = pair.indexOf("\0");
18477
+ const action = idx >= 0 ? pair.slice(idx + 1) : pair;
18478
+ if (!isStructurallyRequired(action)) {
18479
+ nonRequiredGrantedPairs.add(pair);
18480
+ }
18481
+ }
18482
+ const allSignedGrantedPairs = new Set(grantedPairs);
18483
+ const seenRawKeys = /* @__PURE__ */ new Set();
18484
+ for (const rawKey of result.selectedActionKeys) {
18485
+ if (seenRawKeys.has(rawKey)) {
18486
+ throw new Error(
18487
+ `OpenKey selectedActionKeys contains duplicate entry ${rawKey}`
18488
+ );
18489
+ }
18490
+ seenRawKeys.add(rawKey);
18491
+ }
18492
+ const selectedResourceActionPairs = /* @__PURE__ */ new Set();
18493
+ for (const rawKey of result.selectedActionKeys) {
18494
+ const parts = rawKey.split("\0");
18495
+ if (parts.length !== 4) {
18496
+ throw new Error(
18497
+ `OpenKey selectedActionKeys entry is malformed (expected canonical 4-part service\\0space\\0path\\0ability): ${rawKey}`
18498
+ );
18499
+ }
18500
+ const canonical = grantedFourPartIndex.get(rawKey);
18501
+ if (!canonical) {
18502
+ throw new Error(
18503
+ `OpenKey selectedActionKeys entry ${rawKey} is not covered by any granted capability`
18504
+ );
18505
+ }
18506
+ selectedResourceActionPairs.add(canonical);
18507
+ }
18508
+ if (nonRequiredGrantedPairs.size > 0) {
18509
+ const missingPairs = [];
18510
+ for (const pair of nonRequiredGrantedPairs) {
18511
+ if (!selectedResourceActionPairs.has(pair)) {
18512
+ missingPairs.push(pair);
18513
+ }
18514
+ }
18515
+ if (missingPairs.length > 0) {
18516
+ const previewLimit = 5;
18517
+ const preview = missingPairs.slice(0, previewLimit).map((p) => p.replace("\0", "::")).join(", ");
18518
+ const overflow = missingPairs.length > previewLimit ? ` (and ${missingPairs.length - previewLimit} more)` : "";
18519
+ throw new Error(
18520
+ `OpenKey selectedActionKeys is missing entries for signedMessage capabilities: ${preview}${overflow}`
18521
+ );
18522
+ }
18523
+ }
18524
+ if (allSignedGrantedPairs.size > 0 && result.permissions.length === 0) {
18525
+ throw new Error(
18526
+ "OpenKey permissions is empty but signedMessage carries capabilities \u2014 refusing to accept a session without an authoritative grant list"
18527
+ );
18528
+ }
18529
+ const permissionCanonicalKeys = /* @__PURE__ */ new Set();
18530
+ const permissionPairsCovered = /* @__PURE__ */ new Set();
18531
+ for (const perm of result.permissions) {
18532
+ const { service, space, path, actions } = perm;
18533
+ if (typeof service !== "string" || typeof space !== "string" || typeof path !== "string") {
18534
+ throw new Error(
18535
+ `OpenKey permissions entry has non-string service/space/path (service=${service}, space=${space}, path=${path})`
18536
+ );
18537
+ }
18538
+ if (!Array.isArray(actions) || actions.length === 0) {
18539
+ throw new Error(
18540
+ `OpenKey permissions entry (${service} ${space} ${path}) has empty actions \u2014 a capability-bearing entry must list at least one action`
18541
+ );
18542
+ }
18543
+ const canonicalKey = `${service}\0${space}\0${path}`;
18544
+ if (permissionCanonicalKeys.has(canonicalKey)) {
18545
+ throw new Error(
18546
+ `OpenKey permissions has duplicate entry for (${service} ${space} ${path})`
18547
+ );
18548
+ }
18549
+ permissionCanonicalKeys.add(canonicalKey);
18550
+ let expectedResource;
18551
+ if (space.startsWith("tinycloud:")) {
18552
+ const shortService = service.startsWith("tinycloud.") ? service.slice("tinycloud.".length) : service;
18553
+ expectedResource = path ? `${space}/${shortService}/${path}` : `${space}/${shortService}`;
18554
+ } else {
18555
+ if (path) {
18556
+ throw new Error(
18557
+ `OpenKey permissions entry (${service} ${space} ${path}) carries a path on a non-tinycloud: resource URI, which is not a recognized encoding`
18558
+ );
18559
+ }
18560
+ expectedResource = space;
18561
+ }
18562
+ let matchedResource;
18563
+ if (signedCaps[expectedResource] !== void 0) {
18564
+ matchedResource = expectedResource;
18565
+ } else {
18566
+ throw new Error(
18567
+ `OpenKey permissions entry (${service} ${space} ${path}) claims grants not confirmed in signedMessage capabilities`
18568
+ );
18569
+ }
18570
+ const grantedActionsForResource = new Set(
18571
+ Object.keys(signedCaps[matchedResource])
18572
+ );
18573
+ for (const action of actions) {
18574
+ if (typeof action !== "string" || !action) {
18575
+ throw new Error(
18576
+ `OpenKey permissions entry (${service} ${space} ${path}) has non-string action ${String(action)}`
18577
+ );
18578
+ }
18579
+ if (!grantedActionsForResource.has(action)) {
18580
+ throw new Error(
18581
+ `OpenKey permissions entry (${service} ${space} ${path}) claims action ${action} not present in signedMessage capabilities`
18582
+ );
18583
+ }
18584
+ permissionPairsCovered.add(`${matchedResource}\0${action}`);
18585
+ }
18586
+ }
18587
+ if (allSignedGrantedPairs.size > 0) {
18588
+ const missingFromPerms = [];
18589
+ for (const pair of allSignedGrantedPairs) {
18590
+ if (!permissionPairsCovered.has(pair)) {
18591
+ missingFromPerms.push(pair);
18592
+ }
18593
+ }
18594
+ if (missingFromPerms.length > 0) {
18595
+ const previewLimit = 5;
18596
+ const preview = missingFromPerms.slice(0, previewLimit).map((p) => p.replace("\0", "::")).join(", ");
18597
+ const overflow = missingFromPerms.length > previewLimit ? ` (and ${missingFromPerms.length - previewLimit} more)` : "";
18598
+ throw new Error(
18599
+ `OpenKey permissions is missing entries for signedMessage capabilities: ${preview}${overflow}`
18600
+ );
18601
+ }
18602
+ const extrasInPerms = [];
18603
+ for (const pair of permissionPairsCovered) {
18604
+ if (!allSignedGrantedPairs.has(pair)) {
18605
+ extrasInPerms.push(pair);
18606
+ }
18607
+ }
18608
+ if (extrasInPerms.length > 0) {
18609
+ const previewLimit = 5;
18610
+ const preview = extrasInPerms.slice(0, previewLimit).map((p) => p.replace("\0", "::")).join(", ");
18611
+ const overflow = extrasInPerms.length > previewLimit ? ` (and ${extrasInPerms.length - previewLimit} more)` : "";
18612
+ throw new Error(
18613
+ `OpenKey permissions contains entries not present in the signed authority: ${preview}${overflow}`
18614
+ );
18615
+ }
18616
+ }
18617
+ void nonRequiredGrantedPairs;
18618
+ return this.signInWithPreparedSession(
18619
+ {
18620
+ siwe: result.signedMessage,
18621
+ jwk: prepared.jwk,
18622
+ spaceId: prepared.spaceId,
18623
+ verificationMethod: prepared.verificationMethod
18624
+ },
18625
+ result.signature,
18626
+ keyId,
18627
+ jwk
18628
+ );
18629
+ }
18197
18630
  /**
18198
18631
  * Clear persisted session data.
18199
18632
  */
@@ -18449,7 +18882,7 @@ function createWasmKeyProvider(sessionManager) {
18449
18882
  // src/delegateToHelpers.ts
18450
18883
  import {
18451
18884
  parseExpiry,
18452
- SiweMessage,
18885
+ SiweMessage as SiweMessage2,
18453
18886
  EXPIRY as EXPIRY2
18454
18887
  } from "@tinycloud/sdk-core";
18455
18888
  function legacyParamsToPermissionEntries(actions, path, spaceIdOverride) {
@@ -18498,7 +18931,7 @@ function resolveExpiryMs(expiry) {
18498
18931
  return parseExpiry(expiry);
18499
18932
  }
18500
18933
  function extractSiweExpiration(siwe) {
18501
- const parsed = new SiweMessage(siwe);
18934
+ const parsed = new SiweMessage2(siwe);
18502
18935
  if (parsed.expirationTime === void 0 || parsed.expirationTime === null) {
18503
18936
  return void 0;
18504
18937
  }
@@ -19010,7 +19443,9 @@ function base64UrlDecode(value) {
19010
19443
  return new Uint8Array(bytes);
19011
19444
  }
19012
19445
  async function signJwtInputWithJwk(signingInput, jwk) {
19013
- const bytes = new TextEncoder().encode(signingInput);
19446
+ return signBytesWithJwk(new TextEncoder().encode(signingInput), jwk);
19447
+ }
19448
+ async function signBytesWithJwk(bytes, jwk) {
19014
19449
  try {
19015
19450
  const subtle = globalThis.crypto?.subtle;
19016
19451
  if (!subtle) {
@@ -19091,6 +19526,12 @@ var _TinyCloudNode = class _TinyCloudNode {
19091
19526
  this._baseSecrets = /* @__PURE__ */ new Map();
19092
19527
  this._secrets = /* @__PURE__ */ new Map();
19093
19528
  this.runtimePermissionGrants = [];
19529
+ /**
19530
+ * Owned space ids this sign-in has already confirmed are hosted. Consulted by
19531
+ * {@link ensureOwnedSpaceHostedById}; cleared on every {@link signIn} because
19532
+ * hosting is confirmed against the session that was active at the time.
19533
+ */
19534
+ this.confirmedHostedSpaceIds = /* @__PURE__ */ new Set();
19094
19535
  /**
19095
19536
  * True when the last signIn() detected an interactive signer and skipped
19096
19537
  * client-side bootstrap. Apps can read this to know whether bootstrap was
@@ -19101,7 +19542,10 @@ var _TinyCloudNode = class _TinyCloudNode {
19101
19542
  * Outcome of the last signIn()'s account-bootstrap attempt. `skipped` is
19102
19543
  * true when bootstrap did not complete (interactive signer, auto-sign
19103
19544
  * denied, or a bootstrap step failed); `reason` carries the cause so apps
19104
- * can surface a "finish account setup" call-to-action.
19545
+ * can surface a "finish account setup" call-to-action. `warnings` is
19546
+ * present when bootstrap completed, but one or more steps recovered from
19547
+ * an ambiguous failure (e.g. a KV batch write reconciled via per-space
19548
+ * fallback) — clean vs. recovered runs are otherwise byte-identical.
19105
19549
  */
19106
19550
  this._bootstrapStatus = {
19107
19551
  skipped: false
@@ -19487,6 +19931,7 @@ var _TinyCloudNode = class _TinyCloudNode {
19487
19931
  this._spaceService = void 0;
19488
19932
  this._serviceContext = void 0;
19489
19933
  this.runtimePermissionGrants = [];
19934
+ this.confirmedHostedSpaceIds.clear();
19490
19935
  await this.tc.signIn(options);
19491
19936
  this.syncResolvedHostFromAuth();
19492
19937
  const oldGraph = this._serviceGraph;
@@ -19541,7 +19986,13 @@ var _TinyCloudNode = class _TinyCloudNode {
19541
19986
  return false;
19542
19987
  }
19543
19988
  try {
19544
- await this.runAccountBootstrap(steps);
19989
+ const warnings = await this.runAccountBootstrap(steps);
19990
+ if (warnings.length > 0) {
19991
+ this._bootstrapStatus = { skipped: false, warnings };
19992
+ this.notificationHandler.warning(
19993
+ `Account bootstrap completed with warnings: ${warnings.map((w) => `${w.stepId} (${w.kind}: ${w.code})`).join("; ")}`
19994
+ );
19995
+ }
19545
19996
  } catch (err) {
19546
19997
  const reason = err instanceof Error ? err.message : String(err);
19547
19998
  this._bootstrapSkipped = true;
@@ -19590,6 +20041,7 @@ var _TinyCloudNode = class _TinyCloudNode {
19590
20041
  const auth = this.auth;
19591
20042
  const sessions = /* @__PURE__ */ new Map();
19592
20043
  const rawAbilitiesBySpace = /* @__PURE__ */ new Map();
20044
+ const warnings = [];
19593
20045
  const primarySession = auth.tinyCloudSession;
19594
20046
  const defaultSpaceId = this.ownedSpaceId("default");
19595
20047
  const canReusePrimaryBootstrapSession = primarySession?.spaceId === defaultSpaceId && auth.capabilityRequest === BOOTSTRAP_SESSION_REQUESTS.default;
@@ -19651,32 +20103,45 @@ var _TinyCloudNode = class _TinyCloudNode {
19651
20103
  }
19652
20104
  }
19653
20105
  if (step.kind === "seed-spaces") {
19654
- for (const space of step.spaces) {
19655
- const registered = await this.account.spaces.register({
20106
+ const registered = await this.account.spaces.registerBatch(
20107
+ step.spaces.map((space) => ({
19656
20108
  spaceId: space.spaceId,
19657
20109
  name: space.name,
19658
20110
  ownerDid: this.did,
19659
20111
  type: "owned",
19660
20112
  permissions: ["*"],
19661
20113
  status: "active"
20114
+ }))
20115
+ );
20116
+ if (!registered.ok) {
20117
+ throw new Error(
20118
+ `Failed to seed account spaces: ${registered.error.message}`
20119
+ );
20120
+ }
20121
+ const batchError = registered.data.recoveredFromBatchError;
20122
+ if (batchError) {
20123
+ warnings.push({
20124
+ stepId: step.id,
20125
+ kind: "batch-write-reconciled",
20126
+ code: batchError.code,
20127
+ message: batchError.message
19662
20128
  });
19663
- if (!registered.ok) {
19664
- throw new Error(
19665
- `Failed to seed account space ${space.spaceId}: ${registered.error.message}`
19666
- );
19667
- }
19668
20129
  }
19669
20130
  }
19670
20131
  if (step.kind === "seed-applications") {
19671
20132
  const registered = await this.account.applications.register(
19672
- step.manifests.length > 0 ? [...step.manifests] : TINYCLOUD_SECRETS_BOOTSTRAP_MANIFEST
20133
+ step.manifests.length > 0 ? [...step.manifests] : TINYCLOUD_SECRETS_BOOTSTRAP_MANIFEST,
20134
+ // Bootstrap only runs on an account with no registry records, so the
20135
+ // manifest-hash pre-read is definitionally a miss. Skip it; the write
20136
+ // it guards is an INSERT OR REPLACE and is safe to repeat.
20137
+ { assumeUnregistered: true }
19673
20138
  );
19674
20139
  if (!registered.ok) {
19675
20140
  throw new Error(`Failed to seed bootstrap applications: ${registered.error.message}`);
19676
20141
  }
19677
20142
  }
19678
20143
  if (step.kind === "encryption-network-create") {
19679
- await this.ensureEncryptionNetwork(step.networkId);
20144
+ await this.ensureEncryptionNetwork(step.networkId, { assumeMissing: true });
19680
20145
  }
19681
20146
  if (step.kind === "secret-records-schema") {
19682
20147
  const db = this.sqlForSpace(step.spaceId).db(step.database);
@@ -19696,6 +20161,7 @@ var _TinyCloudNode = class _TinyCloudNode {
19696
20161
  }
19697
20162
  }
19698
20163
  }
20164
+ return warnings;
19699
20165
  }
19700
20166
  registerBootstrapRuntimeGrant(session, request, rawAbilities) {
19701
20167
  const operations = [];
@@ -19955,6 +20421,22 @@ var _TinyCloudNode = class _TinyCloudNode {
19955
20421
  await this.ensureEncryptionNetwork(networkId);
19956
20422
  }
19957
20423
  }
20424
+ /**
20425
+ * Ensure one of this user's owned spaces is hosted, at most once per space
20426
+ * per sign-in.
20427
+ *
20428
+ * Account bootstrap funnels six calls through here (via
20429
+ * `AccountService.ensureAccountSpaceHosted`), and each one re-submits
20430
+ * `activateSessionWithHost` with the *primary* session. That session's recap
20431
+ * covers only `default` and `secrets`, so the account space appears in
20432
+ * neither `activated` nor `skipped` and the guard below returns immediately
20433
+ * — the repeat calls cannot change the outcome, they only cost round trips.
20434
+ *
20435
+ * The memo is per sign-in ({@link confirmedHostedSpaceIds} is cleared in
20436
+ * `signIn`) and only records confirmed successes. A wrong skip is
20437
+ * self-revealing: the next write to the space fails immediately and loudly
20438
+ * with `404 Space not found`.
20439
+ */
19958
20440
  async ensureOwnedSpaceHostedById(spaceId) {
19959
20441
  if (!this.auth) {
19960
20442
  throw new Error("Owned space hosting requires wallet mode");
@@ -19963,12 +20445,16 @@ var _TinyCloudNode = class _TinyCloudNode {
19963
20445
  if (!session) {
19964
20446
  throw new Error("Owned space hosting requires an active session");
19965
20447
  }
20448
+ if (this.confirmedHostedSpaceIds.has(spaceId)) {
20449
+ return;
20450
+ }
19966
20451
  const host = this.hosts[0] ?? this.config.host;
19967
20452
  if (!host) {
19968
20453
  throw new Error("Owned space hosting requires a TinyCloud host");
19969
20454
  }
19970
20455
  const activation = await activateSessionWithHost2(host, session.delegationHeader);
19971
20456
  if (activation.success && !activation.skipped?.includes(spaceId)) {
20457
+ this.confirmedHostedSpaceIds.add(spaceId);
19972
20458
  return;
19973
20459
  }
19974
20460
  if (!activation.success && activation.status !== 404) {
@@ -19987,6 +20473,7 @@ var _TinyCloudNode = class _TinyCloudNode {
19987
20473
  `Failed to activate session after creating owned space ${spaceId}: ${retry.error ?? "space was skipped"}`
19988
20474
  );
19989
20475
  }
20476
+ this.confirmedHostedSpaceIds.add(spaceId);
19990
20477
  }
19991
20478
  /**
19992
20479
  * Host one of this user's owned spaces by name (e.g. `"applications"`).
@@ -20302,6 +20789,7 @@ var _TinyCloudNode = class _TinyCloudNode {
20302
20789
  this._delegationManager = stagedGraph.delegationManager;
20303
20790
  this._spaceService = stagedGraph.spaceService;
20304
20791
  this._serviceGraph = stagedGraph.graph;
20792
+ this._activeServiceSession = serviceSession;
20305
20793
  this._baseSecrets = /* @__PURE__ */ new Map();
20306
20794
  this._secrets = /* @__PURE__ */ new Map();
20307
20795
  this._publicKV = void 0;
@@ -20309,6 +20797,7 @@ var _TinyCloudNode = class _TinyCloudNode {
20309
20797
  this.tc = stagedGraph.core;
20310
20798
  this._recapOperationsCache = stagedPrimary.cache;
20311
20799
  this.runtimePermissionGrants = stagedPrimary.grants;
20800
+ this.confirmedHostedSpaceIds.clear();
20312
20801
  if (this.auth) {
20313
20802
  this.auth.installRestoredSession(stagedManager, stagedTcSession, [stagedHost]);
20314
20803
  this._restoredTcSession = void 0;
@@ -20618,6 +21107,28 @@ var _TinyCloudNode = class _TinyCloudNode {
20618
21107
  currentTinyCloudSession() {
20619
21108
  return this.auth?.tinyCloudSession ?? this._restoredTcSession;
20620
21109
  }
21110
+ /**
21111
+ * Mint a multi-capability invocation through the established session key.
21112
+ * Callers receive only the signed headers; session key material remains
21113
+ * owned by the session manager and is never part of the caller contract.
21114
+ */
21115
+ invokeAny(entries, facts) {
21116
+ const session = this.currentTinyCloudSession() ?? this._activeServiceSession;
21117
+ if (session === void 0) throw new Error("Not signed in. Call signIn() first.");
21118
+ return this._serviceGraph.invokeAny(session, entries, facts);
21119
+ }
21120
+ /** Sign protocol bytes with the established in-memory session key. */
21121
+ async signSessionBytes(bytes) {
21122
+ const session = this.currentTinyCloudSession() ?? this._activeServiceSession;
21123
+ if (session === void 0) throw new Error("Not signed in. Call signIn() first.");
21124
+ return signBytesWithJwk(bytes, session.jwk);
21125
+ }
21126
+ /** Bind a session-signed invocation to the canonical service audience. */
21127
+ async bindInvocationAudience(authorization, audience) {
21128
+ const session = this.currentTinyCloudSession() ?? this._activeServiceSession;
21129
+ if (session === void 0) throw new Error("Not signed in. Call signIn() first.");
21130
+ return rewriteInvocationAudience(authorization, audience, session.jwk);
21131
+ }
20621
21132
  /**
20622
21133
  * Connect a wallet to upgrade from session-only mode to wallet mode.
20623
21134
  *
@@ -20799,6 +21310,7 @@ var _TinyCloudNode = class _TinyCloudNode {
20799
21310
  if (this._serviceContext) {
20800
21311
  const spaceScopedContext = this._serviceGraph.track(new ServiceContext2({
20801
21312
  invoke: this._serviceContext.invoke,
21313
+ invokeAny: this._serviceContext.invokeAny,
20802
21314
  fetch: this._serviceContext.fetch,
20803
21315
  hosts: this._serviceContext.hosts,
20804
21316
  telemetry: this.config.telemetry
@@ -20891,7 +21403,17 @@ var _TinyCloudNode = class _TinyCloudNode {
20891
21403
  verifyNodeSignature: (nodeId, message, signature2) => verifyDidKeyEd25519Signature(nodeId, message, signature2)
20892
21404
  };
20893
21405
  }
21406
+ /**
21407
+ * The node DID for {@link config.host}.
21408
+ *
21409
+ * Sign-in already performs `GET /info` for the protocol-version check, and
21410
+ * that response carries `nodeId`, so reuse it rather than fetching `/info` a
21411
+ * second time. Only a value recorded for this exact host qualifies; older
21412
+ * nodes that omit `nodeId` fall through to the fetch.
21413
+ */
20894
21414
  async fetchNodeId() {
21415
+ const cached = this.auth?.nodeIdForHost(this.config.host);
21416
+ if (cached) return cached;
20895
21417
  const response = await fetch(`${this.config.host}/info`);
20896
21418
  if (!response.ok) {
20897
21419
  throw new Error(`Failed to fetch node info: HTTP ${response.status}`);
@@ -21454,6 +21976,7 @@ var _TinyCloudNode = class _TinyCloudNode {
21454
21976
  shareUrl: input.shareUrl,
21455
21977
  documentName: input.documentName,
21456
21978
  jti: base64UrlEncode(crypto.getRandomValues(new Uint8Array(16))),
21979
+ idempotencyKey: input.idempotencyKey,
21457
21980
  expiresAt: input.expiresAt
21458
21981
  };
21459
21982
  const requestBodyDigest = base64UrlEncode(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonicalizeEncryptionJson(body)))));
@@ -21663,6 +22186,13 @@ var _TinyCloudNode = class _TinyCloudNode {
21663
22186
  body
21664
22187
  )
21665
22188
  });
22189
+ if (response.status === 409) {
22190
+ const existing = await this.getEncryptionNetwork(networkId);
22191
+ if (existing) return existing;
22192
+ throw new Error(
22193
+ `Encryption network ${networkId} already exists but could not be read back`
22194
+ );
22195
+ }
21666
22196
  if (!response.ok) {
21667
22197
  throw new Error(
21668
22198
  `Failed to create encryption network ${networkId}: HTTP ${response.status} ${await response.text()}`
@@ -21671,11 +22201,19 @@ var _TinyCloudNode = class _TinyCloudNode {
21671
22201
  const created = await response.json();
21672
22202
  return created.descriptor;
21673
22203
  }
21674
- async ensureEncryptionNetwork(nameOrNetworkId = DEFAULT_ENCRYPTION_NETWORK_NAME) {
22204
+ /**
22205
+ * Ensure an encryption network exists, creating it if necessary.
22206
+ *
22207
+ * @param nameOrNetworkId - Network name or full `urn:tinycloud:encryption:` id.
22208
+ * @param options - See {@link EnsureEncryptionNetworkOptions}.
22209
+ */
22210
+ async ensureEncryptionNetwork(nameOrNetworkId = DEFAULT_ENCRYPTION_NETWORK_NAME, options = {}) {
21675
22211
  const networkId = nameOrNetworkId.startsWith("urn:tinycloud:encryption:") ? nameOrNetworkId : this.getDefaultEncryptionNetworkId(nameOrNetworkId);
21676
- const existing = await this.getEncryptionNetwork(networkId);
21677
- if (existing) {
21678
- return existing;
22212
+ if (!options.assumeMissing) {
22213
+ const existing = await this.getEncryptionNetwork(networkId);
22214
+ if (existing) {
22215
+ return existing;
22216
+ }
21679
22217
  }
21680
22218
  const parsed = parseNetworkId2(networkId);
21681
22219
  if (!didPrincipalMatches2(parsed.ownerDid, this.did)) {
@@ -23692,6 +24230,50 @@ var FileSessionStorage = class {
23692
24230
  }
23693
24231
  };
23694
24232
 
24233
+ // src/authorization/openKeyBridge.ts
24234
+ function wireOpenKeyAuthorize(openkey) {
24235
+ return async (input) => {
24236
+ const result = await openkey.authorizeTinyCloud({
24237
+ protocolVersion: 1,
24238
+ siwe: input.siwe,
24239
+ keyId: input.keyId,
24240
+ jwk: input.jwk,
24241
+ host: input.host,
24242
+ // Forward the presentation envelope verbatim — the OpenKey SDK
24243
+ // validates and size-bounds it before use.
24244
+ presentation: input.presentation
24245
+ });
24246
+ if (result.protocolVersion !== 1) {
24247
+ throw new Error(
24248
+ `wireOpenKeyAuthorize: OpenKey returned unsupported protocolVersion ${result.protocolVersion}`
24249
+ );
24250
+ }
24251
+ if (typeof result.signedMessage !== "string" || !result.signedMessage) {
24252
+ throw new Error(
24253
+ "wireOpenKeyAuthorize: OpenKey returned no signedMessage \u2014 cannot complete a TinyCloud session"
24254
+ );
24255
+ }
24256
+ if (!Array.isArray(result.selectedActionKeys)) {
24257
+ throw new Error(
24258
+ "wireOpenKeyAuthorize: OpenKey returned no selectedActionKeys"
24259
+ );
24260
+ }
24261
+ if (!Array.isArray(result.permissions)) {
24262
+ throw new Error(
24263
+ "wireOpenKeyAuthorize: OpenKey returned no permissions"
24264
+ );
24265
+ }
24266
+ return {
24267
+ protocolVersion: 1,
24268
+ address: result.address,
24269
+ signature: result.signature,
24270
+ signedMessage: result.signedMessage,
24271
+ selectedActionKeys: result.selectedActionKeys,
24272
+ permissions: result.permissions
24273
+ };
24274
+ };
24275
+ }
24276
+
23695
24277
  // src/index.ts
23696
24278
  import {
23697
24279
  ACCOUNT_REGISTRY_PATH,
@@ -24269,7 +24851,8 @@ export {
24269
24851
  tinyCloudDebugLogger,
24270
24852
  validateEnvelope,
24271
24853
  validateManifest,
24272
- verifyDecryptResponse
24854
+ verifyDecryptResponse,
24855
+ wireOpenKeyAuthorize
24273
24856
  };
24274
24857
  /*! Bundled license information:
24275
24858