@tinycloud/node-sdk 2.11.0-beta.0 → 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-B_i2KfID.d.cts → core-BypriIWK.d.cts} +254 -20
- package/dist/{core-B_i2KfID.d.ts → core-BypriIWK.d.ts} +254 -20
- package/dist/core.cjs +645 -35
- package/dist/core.cjs.map +1 -1
- package/dist/core.d.cts +2 -2
- package/dist/core.d.ts +2 -2
- package/dist/core.js +652 -37
- package/dist/core.js.map +1 -1
- package/dist/index.cjs +693 -37
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +86 -4
- package/dist/index.d.ts +86 -4
- package/dist/index.js +698 -38
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
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
|
-
|
|
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
|
-
|
|
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
|
|
1924
|
+
const parsed = new SiweMessage2(siwe);
|
|
1492
1925
|
if (parsed.expirationTime === void 0 || parsed.expirationTime === null) {
|
|
1493
1926
|
return void 0;
|
|
1494
1927
|
}
|
|
@@ -1883,19 +2316,87 @@ function persistedExpiry(value) {
|
|
|
1883
2316
|
function sameInstant(left, right) {
|
|
1884
2317
|
return left.getTime() === right.getTime();
|
|
1885
2318
|
}
|
|
1886
|
-
function
|
|
1887
|
-
|
|
2319
|
+
function ownerDelegationPermissions(params) {
|
|
2320
|
+
const hasPermissions = Object.prototype.hasOwnProperty.call(params, "permissions");
|
|
2321
|
+
const hasLegacy = Object.prototype.hasOwnProperty.call(params, "path") || Object.prototype.hasOwnProperty.call(params, "actions");
|
|
2322
|
+
if (hasPermissions === hasLegacy) {
|
|
2323
|
+
throw new Error("Owner delegation requires exactly one authority shape: path/actions or permissions");
|
|
2324
|
+
}
|
|
2325
|
+
const permissions = [];
|
|
2326
|
+
if (hasPermissions) {
|
|
2327
|
+
if (!Array.isArray(params.permissions)) {
|
|
2328
|
+
throw new Error("Owner delegation permissions are invalid");
|
|
2329
|
+
}
|
|
2330
|
+
permissions.push(...params.permissions.map((permission) => ({
|
|
2331
|
+
service: permission.service,
|
|
2332
|
+
path: permission.path,
|
|
2333
|
+
actions: [...permission.actions]
|
|
2334
|
+
})));
|
|
2335
|
+
} else {
|
|
2336
|
+
if (typeof params.path !== "string" || !Array.isArray(params.actions)) {
|
|
2337
|
+
throw new Error("Owner delegation requires bounded capabilities");
|
|
2338
|
+
}
|
|
2339
|
+
const byService = /* @__PURE__ */ new Map();
|
|
2340
|
+
for (const action of params.actions) {
|
|
2341
|
+
if (typeof action !== "string") {
|
|
2342
|
+
throw new Error("Owner delegation capabilities are unsupported");
|
|
2343
|
+
}
|
|
2344
|
+
const slash = action.indexOf("/");
|
|
2345
|
+
const service = slash === -1 ? "" : action.slice(0, slash);
|
|
2346
|
+
if (SERVICE_LONG_TO_SHORT[service] === void 0) {
|
|
2347
|
+
throw new Error("Owner delegation capabilities are unsupported");
|
|
2348
|
+
}
|
|
2349
|
+
const actions = byService.get(service) ?? [];
|
|
2350
|
+
actions.push(action);
|
|
2351
|
+
byService.set(service, actions);
|
|
2352
|
+
}
|
|
2353
|
+
for (const [service, actions] of byService) {
|
|
2354
|
+
permissions.push({ service, path: params.path, actions });
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
if (permissions.length === 0 || permissions.length > 16) {
|
|
2358
|
+
throw new Error("Owner delegation requires bounded capabilities");
|
|
2359
|
+
}
|
|
2360
|
+
const resources = /* @__PURE__ */ new Set();
|
|
2361
|
+
for (const permission of permissions) {
|
|
2362
|
+
if (typeof permission.service !== "string" || SERVICE_LONG_TO_SHORT[permission.service] === void 0 || typeof permission.path !== "string" || permission.path.length === 0 || permission.path.trim() !== permission.path || /[\u0000-\u001f\u007f\\*]/.test(permission.path) || !Array.isArray(permission.actions) || permission.actions.length === 0 || permission.actions.length > 32) {
|
|
2363
|
+
throw new Error("Owner delegation requires bounded capabilities");
|
|
2364
|
+
}
|
|
2365
|
+
const resourceKey = `${permission.service}\0${permission.path}`;
|
|
2366
|
+
if (resources.has(resourceKey)) {
|
|
2367
|
+
throw new Error("Owner delegation permissions contain duplicate resources");
|
|
2368
|
+
}
|
|
2369
|
+
resources.add(resourceKey);
|
|
2370
|
+
const actions = /* @__PURE__ */ new Set();
|
|
2371
|
+
for (const action of permission.actions) {
|
|
2372
|
+
if (typeof action !== "string" || !action.startsWith(`${permission.service}/`) || action.length === permission.service.length + 1 || actions.has(action)) {
|
|
2373
|
+
throw new Error("Owner delegation capabilities are unsupported");
|
|
2374
|
+
}
|
|
2375
|
+
actions.add(action);
|
|
2376
|
+
}
|
|
2377
|
+
if (permission.service === ENCRYPTION_PERMISSION_SERVICE2) {
|
|
2378
|
+
try {
|
|
2379
|
+
parseNetworkId2(permission.path);
|
|
2380
|
+
} catch {
|
|
2381
|
+
throw new Error("Owner delegation encryption permission requires a canonical network URN");
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
return permissions;
|
|
2386
|
+
}
|
|
2387
|
+
function ownerPermissionsToAbilities(permissions) {
|
|
1888
2388
|
const abilities = {};
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
if (
|
|
1892
|
-
|
|
1893
|
-
|
|
2389
|
+
const rawAbilities = {};
|
|
2390
|
+
for (const permission of permissions) {
|
|
2391
|
+
if (permission.service === ENCRYPTION_PERMISSION_SERVICE2) {
|
|
2392
|
+
rawAbilities[permission.path] = [...permission.actions];
|
|
2393
|
+
continue;
|
|
2394
|
+
}
|
|
2395
|
+
const shortService = SERVICE_LONG_TO_SHORT[permission.service];
|
|
1894
2396
|
abilities[shortService] ?? (abilities[shortService] = {});
|
|
1895
|
-
|
|
1896
|
-
abilities[shortService][path].push(action);
|
|
2397
|
+
abilities[shortService][permission.path] = [...permission.actions];
|
|
1897
2398
|
}
|
|
1898
|
-
return
|
|
2399
|
+
return { abilities, rawAbilities };
|
|
1899
2400
|
}
|
|
1900
2401
|
function base64UrlEncode(bytes) {
|
|
1901
2402
|
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
@@ -1932,7 +2433,9 @@ function base64UrlDecode(value) {
|
|
|
1932
2433
|
return new Uint8Array(bytes);
|
|
1933
2434
|
}
|
|
1934
2435
|
async function signJwtInputWithJwk(signingInput, jwk) {
|
|
1935
|
-
|
|
2436
|
+
return signBytesWithJwk(new TextEncoder().encode(signingInput), jwk);
|
|
2437
|
+
}
|
|
2438
|
+
async function signBytesWithJwk(bytes, jwk) {
|
|
1936
2439
|
try {
|
|
1937
2440
|
const subtle = globalThis.crypto?.subtle;
|
|
1938
2441
|
if (!subtle) {
|
|
@@ -2013,6 +2516,12 @@ var _TinyCloudNode = class _TinyCloudNode {
|
|
|
2013
2516
|
this._baseSecrets = /* @__PURE__ */ new Map();
|
|
2014
2517
|
this._secrets = /* @__PURE__ */ new Map();
|
|
2015
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();
|
|
2016
2525
|
/**
|
|
2017
2526
|
* True when the last signIn() detected an interactive signer and skipped
|
|
2018
2527
|
* client-side bootstrap. Apps can read this to know whether bootstrap was
|
|
@@ -2023,7 +2532,10 @@ var _TinyCloudNode = class _TinyCloudNode {
|
|
|
2023
2532
|
* Outcome of the last signIn()'s account-bootstrap attempt. `skipped` is
|
|
2024
2533
|
* true when bootstrap did not complete (interactive signer, auto-sign
|
|
2025
2534
|
* denied, or a bootstrap step failed); `reason` carries the cause so apps
|
|
2026
|
-
* 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.
|
|
2027
2539
|
*/
|
|
2028
2540
|
this._bootstrapStatus = {
|
|
2029
2541
|
skipped: false
|
|
@@ -2409,6 +2921,7 @@ var _TinyCloudNode = class _TinyCloudNode {
|
|
|
2409
2921
|
this._spaceService = void 0;
|
|
2410
2922
|
this._serviceContext = void 0;
|
|
2411
2923
|
this.runtimePermissionGrants = [];
|
|
2924
|
+
this.confirmedHostedSpaceIds.clear();
|
|
2412
2925
|
await this.tc.signIn(options);
|
|
2413
2926
|
this.syncResolvedHostFromAuth();
|
|
2414
2927
|
const oldGraph = this._serviceGraph;
|
|
@@ -2463,7 +2976,13 @@ var _TinyCloudNode = class _TinyCloudNode {
|
|
|
2463
2976
|
return false;
|
|
2464
2977
|
}
|
|
2465
2978
|
try {
|
|
2466
|
-
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
|
+
}
|
|
2467
2986
|
} catch (err) {
|
|
2468
2987
|
const reason = err instanceof Error ? err.message : String(err);
|
|
2469
2988
|
this._bootstrapSkipped = true;
|
|
@@ -2512,6 +3031,7 @@ var _TinyCloudNode = class _TinyCloudNode {
|
|
|
2512
3031
|
const auth = this.auth;
|
|
2513
3032
|
const sessions = /* @__PURE__ */ new Map();
|
|
2514
3033
|
const rawAbilitiesBySpace = /* @__PURE__ */ new Map();
|
|
3034
|
+
const warnings = [];
|
|
2515
3035
|
const primarySession = auth.tinyCloudSession;
|
|
2516
3036
|
const defaultSpaceId = this.ownedSpaceId("default");
|
|
2517
3037
|
const canReusePrimaryBootstrapSession = primarySession?.spaceId === defaultSpaceId && auth.capabilityRequest === BOOTSTRAP_SESSION_REQUESTS.default;
|
|
@@ -2573,32 +3093,45 @@ var _TinyCloudNode = class _TinyCloudNode {
|
|
|
2573
3093
|
}
|
|
2574
3094
|
}
|
|
2575
3095
|
if (step.kind === "seed-spaces") {
|
|
2576
|
-
|
|
2577
|
-
|
|
3096
|
+
const registered = await this.account.spaces.registerBatch(
|
|
3097
|
+
step.spaces.map((space) => ({
|
|
2578
3098
|
spaceId: space.spaceId,
|
|
2579
3099
|
name: space.name,
|
|
2580
3100
|
ownerDid: this.did,
|
|
2581
3101
|
type: "owned",
|
|
2582
3102
|
permissions: ["*"],
|
|
2583
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
|
|
2584
3118
|
});
|
|
2585
|
-
if (!registered.ok) {
|
|
2586
|
-
throw new Error(
|
|
2587
|
-
`Failed to seed account space ${space.spaceId}: ${registered.error.message}`
|
|
2588
|
-
);
|
|
2589
|
-
}
|
|
2590
3119
|
}
|
|
2591
3120
|
}
|
|
2592
3121
|
if (step.kind === "seed-applications") {
|
|
2593
3122
|
const registered = await this.account.applications.register(
|
|
2594
|
-
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 }
|
|
2595
3128
|
);
|
|
2596
3129
|
if (!registered.ok) {
|
|
2597
3130
|
throw new Error(`Failed to seed bootstrap applications: ${registered.error.message}`);
|
|
2598
3131
|
}
|
|
2599
3132
|
}
|
|
2600
3133
|
if (step.kind === "encryption-network-create") {
|
|
2601
|
-
await this.ensureEncryptionNetwork(step.networkId);
|
|
3134
|
+
await this.ensureEncryptionNetwork(step.networkId, { assumeMissing: true });
|
|
2602
3135
|
}
|
|
2603
3136
|
if (step.kind === "secret-records-schema") {
|
|
2604
3137
|
const db = this.sqlForSpace(step.spaceId).db(step.database);
|
|
@@ -2618,6 +3151,7 @@ var _TinyCloudNode = class _TinyCloudNode {
|
|
|
2618
3151
|
}
|
|
2619
3152
|
}
|
|
2620
3153
|
}
|
|
3154
|
+
return warnings;
|
|
2621
3155
|
}
|
|
2622
3156
|
registerBootstrapRuntimeGrant(session, request, rawAbilities) {
|
|
2623
3157
|
const operations = [];
|
|
@@ -2877,6 +3411,22 @@ var _TinyCloudNode = class _TinyCloudNode {
|
|
|
2877
3411
|
await this.ensureEncryptionNetwork(networkId);
|
|
2878
3412
|
}
|
|
2879
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
|
+
*/
|
|
2880
3430
|
async ensureOwnedSpaceHostedById(spaceId) {
|
|
2881
3431
|
if (!this.auth) {
|
|
2882
3432
|
throw new Error("Owned space hosting requires wallet mode");
|
|
@@ -2885,12 +3435,16 @@ var _TinyCloudNode = class _TinyCloudNode {
|
|
|
2885
3435
|
if (!session) {
|
|
2886
3436
|
throw new Error("Owned space hosting requires an active session");
|
|
2887
3437
|
}
|
|
3438
|
+
if (this.confirmedHostedSpaceIds.has(spaceId)) {
|
|
3439
|
+
return;
|
|
3440
|
+
}
|
|
2888
3441
|
const host = this.hosts[0] ?? this.config.host;
|
|
2889
3442
|
if (!host) {
|
|
2890
3443
|
throw new Error("Owned space hosting requires a TinyCloud host");
|
|
2891
3444
|
}
|
|
2892
3445
|
const activation = await activateSessionWithHost2(host, session.delegationHeader);
|
|
2893
3446
|
if (activation.success && !activation.skipped?.includes(spaceId)) {
|
|
3447
|
+
this.confirmedHostedSpaceIds.add(spaceId);
|
|
2894
3448
|
return;
|
|
2895
3449
|
}
|
|
2896
3450
|
if (!activation.success && activation.status !== 404) {
|
|
@@ -2909,6 +3463,7 @@ var _TinyCloudNode = class _TinyCloudNode {
|
|
|
2909
3463
|
`Failed to activate session after creating owned space ${spaceId}: ${retry.error ?? "space was skipped"}`
|
|
2910
3464
|
);
|
|
2911
3465
|
}
|
|
3466
|
+
this.confirmedHostedSpaceIds.add(spaceId);
|
|
2912
3467
|
}
|
|
2913
3468
|
/**
|
|
2914
3469
|
* Host one of this user's owned spaces by name (e.g. `"applications"`).
|
|
@@ -3224,6 +3779,7 @@ var _TinyCloudNode = class _TinyCloudNode {
|
|
|
3224
3779
|
this._delegationManager = stagedGraph.delegationManager;
|
|
3225
3780
|
this._spaceService = stagedGraph.spaceService;
|
|
3226
3781
|
this._serviceGraph = stagedGraph.graph;
|
|
3782
|
+
this._activeServiceSession = serviceSession;
|
|
3227
3783
|
this._baseSecrets = /* @__PURE__ */ new Map();
|
|
3228
3784
|
this._secrets = /* @__PURE__ */ new Map();
|
|
3229
3785
|
this._publicKV = void 0;
|
|
@@ -3231,6 +3787,7 @@ var _TinyCloudNode = class _TinyCloudNode {
|
|
|
3231
3787
|
this.tc = stagedGraph.core;
|
|
3232
3788
|
this._recapOperationsCache = stagedPrimary.cache;
|
|
3233
3789
|
this.runtimePermissionGrants = stagedPrimary.grants;
|
|
3790
|
+
this.confirmedHostedSpaceIds.clear();
|
|
3234
3791
|
if (this.auth) {
|
|
3235
3792
|
this.auth.installRestoredSession(stagedManager, stagedTcSession, [stagedHost]);
|
|
3236
3793
|
this._restoredTcSession = void 0;
|
|
@@ -3540,6 +4097,28 @@ var _TinyCloudNode = class _TinyCloudNode {
|
|
|
3540
4097
|
currentTinyCloudSession() {
|
|
3541
4098
|
return this.auth?.tinyCloudSession ?? this._restoredTcSession;
|
|
3542
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
|
+
}
|
|
3543
4122
|
/**
|
|
3544
4123
|
* Connect a wallet to upgrade from session-only mode to wallet mode.
|
|
3545
4124
|
*
|
|
@@ -3721,6 +4300,7 @@ var _TinyCloudNode = class _TinyCloudNode {
|
|
|
3721
4300
|
if (this._serviceContext) {
|
|
3722
4301
|
const spaceScopedContext = this._serviceGraph.track(new ServiceContext2({
|
|
3723
4302
|
invoke: this._serviceContext.invoke,
|
|
4303
|
+
invokeAny: this._serviceContext.invokeAny,
|
|
3724
4304
|
fetch: this._serviceContext.fetch,
|
|
3725
4305
|
hosts: this._serviceContext.hosts,
|
|
3726
4306
|
telemetry: this.config.telemetry
|
|
@@ -3813,7 +4393,17 @@ var _TinyCloudNode = class _TinyCloudNode {
|
|
|
3813
4393
|
verifyNodeSignature: (nodeId, message, signature) => verifyDidKeyEd25519Signature(nodeId, message, signature)
|
|
3814
4394
|
};
|
|
3815
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
|
+
*/
|
|
3816
4404
|
async fetchNodeId() {
|
|
4405
|
+
const cached = this.auth?.nodeIdForHost(this.config.host);
|
|
4406
|
+
if (cached) return cached;
|
|
3817
4407
|
const response = await fetch(`${this.config.host}/info`);
|
|
3818
4408
|
if (!response.ok) {
|
|
3819
4409
|
throw new Error(`Failed to fetch node info: HTTP ${response.status}`);
|
|
@@ -4269,21 +4859,29 @@ var _TinyCloudNode = class _TinyCloudNode {
|
|
|
4269
4859
|
async createOwnerDelegation(params, assertActive) {
|
|
4270
4860
|
const assertOwnerGraphActive = assertActive ?? this._serviceGraph.assertActive.bind(this._serviceGraph);
|
|
4271
4861
|
assertOwnerGraphActive();
|
|
4272
|
-
if (!params.delegateDid.startsWith("did:key:")
|
|
4862
|
+
if (!params.delegateDid.startsWith("did:key:")) {
|
|
4273
4863
|
throw new Error("Owner delegation requires an external did:key audience and bounded capabilities");
|
|
4274
4864
|
}
|
|
4865
|
+
const permissions = ownerDelegationPermissions(params);
|
|
4275
4866
|
const now = /* @__PURE__ */ new Date();
|
|
4276
|
-
if (params.expiresAt.getTime() <= now.getTime() || params.expiresAt.getTime() - now.getTime() > EXPIRY3.MAX_MS) {
|
|
4867
|
+
if (!(params.expiresAt instanceof Date) || params.expiresAt.getTime() <= now.getTime() || params.expiresAt.getTime() - now.getTime() > EXPIRY3.MAX_MS) {
|
|
4277
4868
|
throw new Error("Owner delegation expiry must be explicit, future, and within EXPIRY.MAX_MS");
|
|
4278
4869
|
}
|
|
4279
4870
|
if (!this.signer) throw new Error("Owner wallet signer is required");
|
|
4280
4871
|
const session = this.currentTinyCloudSession();
|
|
4281
4872
|
if (!session) throw new Error("Owner session is required");
|
|
4282
|
-
const
|
|
4283
|
-
|
|
4873
|
+
const ownerDid = pkhDid2(session.address, session.chainId);
|
|
4874
|
+
for (const permission of permissions) {
|
|
4875
|
+
if (permission.service === ENCRYPTION_PERMISSION_SERVICE2 && !principalDidEquals2(parseNetworkId2(permission.path).ownerDid, ownerDid)) {
|
|
4876
|
+
throw new Error("Owner delegation cannot grant a foreign encryption network");
|
|
4877
|
+
}
|
|
4878
|
+
}
|
|
4879
|
+
const { abilities, rawAbilities } = ownerPermissionsToAbilities(permissions);
|
|
4880
|
+
const primary = permissions[0];
|
|
4284
4881
|
const host = this.config.host;
|
|
4285
4882
|
const prepared = this.wasmBindings.prepareSession({
|
|
4286
4883
|
abilities,
|
|
4884
|
+
...Object.keys(rawAbilities).length === 0 ? {} : { rawAbilities },
|
|
4287
4885
|
address: this.wasmBindings.ensureEip55(session.address),
|
|
4288
4886
|
chainId: session.chainId,
|
|
4289
4887
|
domain: this.siweDomain,
|
|
@@ -4303,10 +4901,10 @@ var _TinyCloudNode = class _TinyCloudNode {
|
|
|
4303
4901
|
const delegation = {
|
|
4304
4902
|
cid: delegationSession.delegationCid,
|
|
4305
4903
|
delegateDID: params.delegateDid,
|
|
4306
|
-
delegatorDID:
|
|
4904
|
+
delegatorDID: ownerDid,
|
|
4307
4905
|
spaceId: params.spaceId,
|
|
4308
|
-
path:
|
|
4309
|
-
actions: [...
|
|
4906
|
+
path: primary.path,
|
|
4907
|
+
actions: [...primary.actions],
|
|
4310
4908
|
expiry: params.expiresAt,
|
|
4311
4909
|
isRevoked: false,
|
|
4312
4910
|
allowSubDelegation: true,
|
|
@@ -4317,6 +4915,7 @@ var _TinyCloudNode = class _TinyCloudNode {
|
|
|
4317
4915
|
delegation,
|
|
4318
4916
|
signedDagCbor: decodeAuthorizationBytes(delegationSession.delegationHeader.Authorization),
|
|
4319
4917
|
delegationCid: delegationSession.delegationCid,
|
|
4918
|
+
permissions,
|
|
4320
4919
|
nodeReceipt: {
|
|
4321
4920
|
commitEventCid: activation.commitEventCid,
|
|
4322
4921
|
activated: activation.activated ?? [],
|
|
@@ -4367,6 +4966,7 @@ var _TinyCloudNode = class _TinyCloudNode {
|
|
|
4367
4966
|
shareUrl: input.shareUrl,
|
|
4368
4967
|
documentName: input.documentName,
|
|
4369
4968
|
jti: base64UrlEncode(crypto.getRandomValues(new Uint8Array(16))),
|
|
4969
|
+
idempotencyKey: input.idempotencyKey,
|
|
4370
4970
|
expiresAt: input.expiresAt
|
|
4371
4971
|
};
|
|
4372
4972
|
const requestBodyDigest = base64UrlEncode(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonicalizeEncryptionJson(body)))));
|
|
@@ -4576,6 +5176,13 @@ var _TinyCloudNode = class _TinyCloudNode {
|
|
|
4576
5176
|
body
|
|
4577
5177
|
)
|
|
4578
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
|
+
}
|
|
4579
5186
|
if (!response.ok) {
|
|
4580
5187
|
throw new Error(
|
|
4581
5188
|
`Failed to create encryption network ${networkId}: HTTP ${response.status} ${await response.text()}`
|
|
@@ -4584,11 +5191,19 @@ var _TinyCloudNode = class _TinyCloudNode {
|
|
|
4584
5191
|
const created = await response.json();
|
|
4585
5192
|
return created.descriptor;
|
|
4586
5193
|
}
|
|
4587
|
-
|
|
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 = {}) {
|
|
4588
5201
|
const networkId = nameOrNetworkId.startsWith("urn:tinycloud:encryption:") ? nameOrNetworkId : this.getDefaultEncryptionNetworkId(nameOrNetworkId);
|
|
4589
|
-
|
|
4590
|
-
|
|
4591
|
-
|
|
5202
|
+
if (!options.assumeMissing) {
|
|
5203
|
+
const existing = await this.getEncryptionNetwork(networkId);
|
|
5204
|
+
if (existing) {
|
|
5205
|
+
return existing;
|
|
5206
|
+
}
|
|
4592
5207
|
}
|
|
4593
5208
|
const parsed = parseNetworkId2(networkId);
|
|
4594
5209
|
if (!didPrincipalMatches2(parsed.ownerDid, this.did)) {
|