@spfn/auth 0.2.0-beta.88 → 0.2.0-beta.89

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/README.md CHANGED
@@ -213,11 +213,24 @@ cut off anything they no longer recognise.
213
213
 
214
214
  ```typescript
215
215
  const { keys } = await authApi.listKeys.call({ body: {} });
216
- // → [{ keyId, deviceName?, platform?, algorithm, fingerprintPrefix, createdAt,
217
- // lastUsedAt?, expiresAt?, isExpired, isActive, revokedAt? }]
216
+ // → [{ keyId, deviceName?, platform?, algorithm, fingerprintPrefix, createdAtMillis,
217
+ // lastUsedAtMillis?, expiresAtMillis?, isExpired, isActive, revokedAtMillis? }]
218
218
 
219
219
  await authApi.listKeys.call({ body: { includeRevoked: true } }); // also what was cut off
220
+ ```
221
+
222
+ Every moment is epoch milliseconds, not an ISO string — one representation across the whole
223
+ surface, so a generated Swift or Kotlin client reads an integer instead of choosing a date
224
+ formatter. This changed in mobile contract 0.5.0; an app still reading `createdAt` moves to
225
+ `createdAtMillis`.
220
226
 
227
+ `algorithm` is the `KeyAlgorithm` enum from contract 0.6.0 rather than a bare string — the routes
228
+ have always constrained it to those values, and the contract had been understating the server. The
229
+ declared values are the ones the server accepts and sends **now**: one can be added, and one can be
230
+ withdrawn for a weakness found later, so a generated client should be built to meet a value it does
231
+ not recognise rather than assume the set is closed.
232
+
233
+ ```typescript
221
234
  await authApi.revokeKey.call({ body: { keyId } }); // → { keyId, selfRevoked }
222
235
  await authApi.revokeAllKeys.call({ body: {} }); // other devices only
223
236
  await authApi.revokeAllKeys.call({ body: { includeCurrent: true } }); // everything
@@ -853,6 +866,44 @@ HTTP status).
853
866
  construction or through the `/control/register-key` hook; the private half never reaches
854
867
  the server. No persistence — a production enrollment/rotation story is phase 2.
855
868
 
869
+ ### The contract version on the wire (contract 0.6.0)
870
+
871
+ A client compiled and shipped separately from the server cannot be fixed by redeploying. Until
872
+ 0.6.0 a mismatch between what that client was generated against and what the server serves
873
+ surfaced as an undecodable body: the app looked broken and nothing said why.
874
+
875
+ Both ends now say what they are.
876
+
877
+ | Header | Direction | Sent by |
878
+ |--------|-----------|---------|
879
+ | `x-spfn-client-kind` | request | every client — `web`, `ios` or `android` |
880
+ | `x-spfn-client-version` | request | the client's own release: a store version, or a bundle build |
881
+ | `x-spfn-client-contract-version` | request | `ios` and `android` only |
882
+ | `x-spfn-server-contract-version` | response | the server, on every response including a refusal |
883
+ | `x-spfn-supported-contract-range` | response | the server, likewise |
884
+
885
+ ```typescript
886
+ import { createClientVersionMiddleware } from '@spfn/auth/client-proof';
887
+
888
+ // Mount before authentication: enrollment and login carry no proof, and they are
889
+ // where a stale client arrives first.
890
+ app.use('*', createClientVersionMiddleware());
891
+ ```
892
+
893
+ - **`web` states no contract version**, because a browser bundle is deployed with the server that
894
+ serves it and has no second version to reconcile. It is exempt by construction, not by leniency.
895
+ - **An `ios` or `android` client that states no contract version, or one outside the range, is
896
+ refused** `CONTRACT_UNSUPPORTED` (409) with the usual envelope.
897
+ - **A request naming no kind passes** — a curl, a health probe, a server-to-server call is not a
898
+ deployed client this rule is about.
899
+ - **None of it enters the proof input.** These are diagnostic; `PROOF_INPUT_FIELDS` is unchanged.
900
+ - **The server states facts and stops there.** Comparing the announced range against its own version
901
+ and deciding a user should see an update prompt is the client's judgment, made in the client. The
902
+ server has no way to make an app update and does not pretend to.
903
+
904
+ Response header names are deliberately distinct from the request ones: a proxy that echoes a request
905
+ header into the response would otherwise make the client's own version look like the server's.
906
+
856
907
  ### Usage — dev surface (mobile integration target)
857
908
 
858
909
  The fastest path: run the packaged dev handler, which already serves the three contract
@@ -270,15 +270,23 @@ interface KeySummary {
270
270
  algorithm: KeyAlgorithmType;
271
271
  /** First bytes of the fingerprint — enough to tell two entries apart. */
272
272
  fingerprintPrefix: string;
273
- createdAt: string;
274
- lastUsedAt?: string;
275
- expiresAt?: string;
273
+ /**
274
+ * Milliseconds since the Unix epoch, not an ISO string.
275
+ *
276
+ * One representation of a moment across the whole surface: a generated Swift
277
+ * or Kotlin client reads an integer with no date formatter, and
278
+ * `ISO8601DateFormatter` rejecting fractional seconds by default stops being
279
+ * a way for the two SDKs to disagree about the same value.
280
+ */
281
+ createdAtMillis: number;
282
+ lastUsedAtMillis?: number;
283
+ expiresAtMillis?: number;
276
284
  /** The TTL has run out. The key still reads as active; authenticate refuses it. */
277
285
  isExpired: boolean;
278
286
  /** False once revoked. Only ever false when the caller asked for revoked keys. */
279
287
  isActive: boolean;
280
288
  /** When it was revoked, for the "what did I cut off, and when" reading. */
281
- revokedAt?: string;
289
+ revokedAtMillis?: number;
282
290
  }
283
291
  interface ListKeysParams {
284
292
  userId: number;
@@ -871,7 +879,7 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
871
879
  id: number;
872
880
  name: string;
873
881
  displayName: string;
874
- category: "custom" | "user" | "auth" | "rbac" | "system" | undefined;
882
+ category: "auth" | "custom" | "user" | "rbac" | "system" | undefined;
875
883
  }[];
876
884
  userId: number;
877
885
  publicId: string;
@@ -1171,8 +1179,8 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
1171
1179
  }, {}, {
1172
1180
  roles: {
1173
1181
  description: string | null;
1174
- id: number;
1175
1182
  name: string;
1183
+ id: number;
1176
1184
  displayName: string;
1177
1185
  isBuiltin: boolean;
1178
1186
  isSystem: boolean;
@@ -1193,8 +1201,8 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
1193
1201
  }, {}, {
1194
1202
  role: {
1195
1203
  description: string | null;
1196
- id: number;
1197
1204
  name: string;
1205
+ id: number;
1198
1206
  displayName: string;
1199
1207
  isBuiltin: boolean;
1200
1208
  isSystem: boolean;
@@ -1217,8 +1225,8 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
1217
1225
  }, {}, {
1218
1226
  role: {
1219
1227
  description: string | null;
1220
- id: number;
1221
1228
  name: string;
1229
+ id: number;
1222
1230
  displayName: string;
1223
1231
  isBuiltin: boolean;
1224
1232
  isSystem: boolean;
@@ -149,6 +149,14 @@ declare class ClientProofRefusal {
149
149
  static bodyNotTheDeclaredType(): ClientProofRefusal;
150
150
  static sessionHeaderMisplaced(): ClientProofRefusal;
151
151
  static unprocessable(): ClientProofRefusal;
152
+ /**
153
+ * A client that ships separately from the server said nothing about which
154
+ * contract it was built against. Without it the server cannot tell whether
155
+ * the two ends agree, and answering as though they do is what produces the
156
+ * undecodable body this check exists to replace.
157
+ */
158
+ static contractVersionMissing(): ClientProofRefusal;
159
+ static contractVersionUnsupported(): ClientProofRefusal;
152
160
  static profileRejected(): ClientProofRefusal;
153
161
  static sessionRevoked(): ClientProofRefusal;
154
162
  static proofExpired(): ClientProofRefusal;
@@ -554,4 +562,116 @@ interface ClientProofGuardOptions {
554
562
  */
555
563
  declare function createClientProofGuard(state: ClientProofState, options?: ClientProofGuardOptions): MiddlewareHandler;
556
564
 
557
- export { ABSENT_BODY_SHA256, AUTH_SURFACE_OPERATIONS, type Admission, CLIENT_PROOF_CONTENT_TYPE, CLIENT_PROOF_HEADERS, CLIENT_PROOF_PROFILE, CONTRACT_OPERATIONS, CONTROL_PREFIX, CONTROL_TOKEN_HEADER, CanonicalJsonError, type CanonicalJsonErrorCode, type CanonicalObject, type CanonicalValue, type ClientProofClock, type ClientProofContext, type ClientProofCredentials, type ClientProofDevHandler, type ClientProofDevHandlerOptions, type ClientProofErrorCode, type ClientProofGuardOptions, type ClientProofInput, ClientProofRefusal, type ClientProofReplayStore, ClientProofState, type ClientProofStateOptions, type ClientProofStats, type ContractItem, type ContractOperation, ContractTypeError, DEFAULT_REPLAY_WINDOW_MILLIS, DEFAULT_SESSION_TTL_MILLIS, DEV_CATALOGUE, DEV_MAX_LIMIT, type EchoRequest, type HandshakeRequest, type ListItemsRequest, MemoryReplayLedger, MemoryReplayStore, PROOF_SIGNATURE_BYTES, PROOF_SIGNATURE_HEX_LENGTH, ProofInputError, RedisReplayStore, TestClock, admitClientProofRequest, canonicalProofInput, configureClientProofReplayStore, createClientProofDevHandler, createClientProofGuard, decodeEchoRequest, decodeHandshakeRequest, decodeListItemsRequest, encodeCanonicalJson, encodeEchoResponse, encodeHandshakeResponse, encodeListItemsResponse, getClientProofReplayStore, isCanonicalBytes, isRequestContentType, newHexId, parseCanonicalJson, parseClientProofPublicKey, readCredentials, replayLedgerKey, sha256Hex, signClientProof, systemClock, verifyClientProof };
565
+ /**
566
+ * The header names each end announces itself under.
567
+ *
568
+ * Separated from the logic that reads them so the contract bundle can name them
569
+ * without importing the version comparison, which reads the bundle back. These
570
+ * are declarations and depend on nothing.
571
+ *
572
+ * @module server/client-proof/wire-headers
573
+ */
574
+ /** What a client says about itself, one header each. */
575
+ declare const CLIENT_IDENTITY_HEADERS: {
576
+ readonly kind: "x-spfn-client-kind";
577
+ readonly version: "x-spfn-client-version";
578
+ readonly contractVersion: "x-spfn-client-contract-version";
579
+ };
580
+ /**
581
+ * What the server says about itself, on every response.
582
+ *
583
+ * Distinct names from the request headers on purpose: a proxy that echoes a
584
+ * request header into the response would otherwise make the client's own
585
+ * version look like the server's.
586
+ */
587
+ declare const SERVER_CONTRACT_HEADERS: {
588
+ readonly version: "x-spfn-server-contract-version";
589
+ readonly supportedRange: "x-spfn-supported-contract-range";
590
+ };
591
+ /**
592
+ * The client kinds the server distinguishes.
593
+ *
594
+ * `web` is separated from the two app kinds because it carries no contract
595
+ * version: a browser bundle is deployed with the server that serves it, so
596
+ * there is no second version to reconcile.
597
+ */
598
+ declare const CLIENT_KINDS: readonly ["web", "ios", "android"];
599
+ type ClientKind = typeof CLIENT_KINDS[number];
600
+ /** A kind that ships independently of the server, so its contract version matters. */
601
+ declare function isAppKind(kind: ClientKind): boolean;
602
+
603
+ /** What one request announced about the client that sent it. */
604
+ interface ClientIdentity {
605
+ kind: ClientKind;
606
+ /** The client's own release — a store version, or a bundle build. */
607
+ version: string | null;
608
+ /** The contract version the client was generated from. Never set for `web`. */
609
+ contractVersion: string | null;
610
+ }
611
+ /**
612
+ * Reads the identity headers, or null when the kind is absent or unrecognised.
613
+ *
614
+ * Null is not by itself a refusal — a request from something that predates
615
+ * these headers reaches here too. `judgeClientIdentity` decides.
616
+ */
617
+ declare function readClientIdentity(headers: Headers): ClientIdentity | null;
618
+ /**
619
+ * Whether the server serves what the client was generated against.
620
+ *
621
+ * Under 0.x the minor carries breaking changes, so a supported client agrees on
622
+ * major and minor. From 1.0.0 the major alone decides. This is the rule
623
+ * `CONTRACT_SUPPORTED_RANGE` spells out; keeping it as a comparison rather than
624
+ * parsing that string leaves one place to change when the line reaches 1.0.0.
625
+ */
626
+ declare function isContractVersionSupported(clientVersion: string): boolean;
627
+ /**
628
+ * The refusal a request's announced identity earns, or null to let it through.
629
+ *
630
+ * An app kind must state a contract version this server serves. A version it
631
+ * does not serve, and the absence of one, are the same answer: the two ends do
632
+ * not agree on what the contract is, which is what CONTRACT_UNSUPPORTED means.
633
+ * The response carries the server's version and range, so the client can say
634
+ * which way the gap runs.
635
+ *
636
+ * `web` is exempt from the contract check by construction, not by leniency.
637
+ *
638
+ * A request with no recognised kind passes. The check is on what a client says
639
+ * about itself, and a caller that says nothing — a curl, a health probe, a
640
+ * server-to-server call — is not a deployed client this rule is about.
641
+ */
642
+ declare function judgeClientIdentity(identity: ClientIdentity | null): ClientProofRefusal | null;
643
+ /** Writes the server's own announcement onto a response's headers. */
644
+ declare function applyServerContractHeaders(headers: Headers): void;
645
+ /** The same announcement as a plain object, for a response built from one. */
646
+ declare function serverContractHeaders(): Record<string, string>;
647
+
648
+ /**
649
+ * The version announcement, applied to every request rather than to the proven
650
+ * ones.
651
+ *
652
+ * Enrollment and login are the first calls a client makes and they carry no
653
+ * proof — there is no key to sign with yet. A check that lives inside proof
654
+ * admission therefore never sees the client it is meant to catch: an outdated
655
+ * app fails at login, before it reaches anything proven. This runs ahead of all
656
+ * of it.
657
+ *
658
+ * hono is imported as types only, so this module adds no runtime dependency.
659
+ *
660
+ * @module server/client-proof/version-middleware
661
+ */
662
+
663
+ /** The context key the identity is left under, for a handler that wants it. */
664
+ declare const CLIENT_IDENTITY_CONTEXT_KEY = "clientIdentity";
665
+ /**
666
+ * Announces the server's contract version on every response and refuses a
667
+ * client whose own contract version this server does not serve.
668
+ *
669
+ * The announcement goes out either way. A refused client needs it most — the
670
+ * refusal says the two ends disagree, and the range is what says how.
671
+ *
672
+ * Mount this before authentication, not after: the point is to answer a stale
673
+ * client before anything else has a chance to fail confusingly.
674
+ */
675
+ declare function createClientVersionMiddleware(): MiddlewareHandler;
676
+
677
+ export { ABSENT_BODY_SHA256, AUTH_SURFACE_OPERATIONS, type Admission, CLIENT_IDENTITY_CONTEXT_KEY, CLIENT_IDENTITY_HEADERS, CLIENT_KINDS, CLIENT_PROOF_CONTENT_TYPE, CLIENT_PROOF_HEADERS, CLIENT_PROOF_PROFILE, CONTRACT_OPERATIONS, CONTROL_PREFIX, CONTROL_TOKEN_HEADER, CanonicalJsonError, type CanonicalJsonErrorCode, type CanonicalObject, type CanonicalValue, type ClientIdentity, type ClientKind, type ClientProofClock, type ClientProofContext, type ClientProofCredentials, type ClientProofDevHandler, type ClientProofDevHandlerOptions, type ClientProofErrorCode, type ClientProofGuardOptions, type ClientProofInput, ClientProofRefusal, type ClientProofReplayStore, ClientProofState, type ClientProofStateOptions, type ClientProofStats, type ContractItem, type ContractOperation, ContractTypeError, DEFAULT_REPLAY_WINDOW_MILLIS, DEFAULT_SESSION_TTL_MILLIS, DEV_CATALOGUE, DEV_MAX_LIMIT, type EchoRequest, type HandshakeRequest, type ListItemsRequest, MemoryReplayLedger, MemoryReplayStore, PROOF_SIGNATURE_BYTES, PROOF_SIGNATURE_HEX_LENGTH, ProofInputError, RedisReplayStore, SERVER_CONTRACT_HEADERS, TestClock, admitClientProofRequest, applyServerContractHeaders, canonicalProofInput, configureClientProofReplayStore, createClientProofDevHandler, createClientProofGuard, createClientVersionMiddleware, decodeEchoRequest, decodeHandshakeRequest, decodeListItemsRequest, encodeCanonicalJson, encodeEchoResponse, encodeHandshakeResponse, encodeListItemsResponse, getClientProofReplayStore, isAppKind, isCanonicalBytes, isContractVersionSupported, isRequestContentType, judgeClientIdentity, newHexId, parseCanonicalJson, parseClientProofPublicKey, readClientIdentity, readCredentials, replayLedgerKey, serverContractHeaders, sha256Hex, signClientProof, systemClock, verifyClientProof };
@@ -496,6 +496,18 @@ var ClientProofRefusal = class _ClientProofRefusal {
496
496
  static unprocessable() {
497
497
  return contractViolation("the request could not be processed");
498
498
  }
499
+ /**
500
+ * A client that ships separately from the server said nothing about which
501
+ * contract it was built against. Without it the server cannot tell whether
502
+ * the two ends agree, and answering as though they do is what produces the
503
+ * undecodable body this check exists to replace.
504
+ */
505
+ static contractVersionMissing() {
506
+ return contractViolation("a client of this kind must state the contract version it was generated from");
507
+ }
508
+ static contractVersionUnsupported() {
509
+ return contractViolation("the stated contract version is outside the range this server serves");
510
+ }
499
511
  // ---- the profile allowlist ----------------------------------------------
500
512
  static profileRejected() {
501
513
  return new _ClientProofRefusal("PROFILE_REJECTED", "the named auth profile is not on this contract's allowlist");
@@ -1045,17 +1057,17 @@ function decodeListItemsRequest(value) {
1045
1057
  }
1046
1058
  return request;
1047
1059
  }
1048
- function objectWithKeys(value, required, optional) {
1060
+ function objectWithKeys(value, required2, optional2) {
1049
1061
  if (!(value instanceof Map)) {
1050
1062
  throw new ContractTypeError();
1051
1063
  }
1052
- for (const key of required) {
1064
+ for (const key of required2) {
1053
1065
  if (!value.has(key)) {
1054
1066
  throw new ContractTypeError();
1055
1067
  }
1056
1068
  }
1057
1069
  for (const key of value.keys()) {
1058
- if (!required.includes(key) && !optional.includes(key)) {
1070
+ if (!required2.includes(key) && !optional2.includes(key)) {
1059
1071
  throw new ContractTypeError();
1060
1072
  }
1061
1073
  }
@@ -1251,6 +1263,289 @@ function answer(status, value) {
1251
1263
  return new Response(buffer, { status, headers: { "content-type": "application/json" } });
1252
1264
  }
1253
1265
 
1266
+ // src/server/client-proof/contract-bundle.ts
1267
+ import { createHash as createHash2 } from "crypto";
1268
+
1269
+ // src/server/types.ts
1270
+ var KEY_ALGORITHM = ["ES256", "RS256"];
1271
+
1272
+ // src/server/client-proof/wire-headers.ts
1273
+ var CLIENT_IDENTITY_HEADERS = {
1274
+ kind: "x-spfn-client-kind",
1275
+ version: "x-spfn-client-version",
1276
+ contractVersion: "x-spfn-client-contract-version"
1277
+ };
1278
+ var SERVER_CONTRACT_HEADERS = {
1279
+ version: "x-spfn-server-contract-version",
1280
+ supportedRange: "x-spfn-supported-contract-range"
1281
+ };
1282
+ var CLIENT_KINDS = ["web", "ios", "android"];
1283
+ function isAppKind(kind) {
1284
+ return kind !== "web";
1285
+ }
1286
+
1287
+ // src/server/client-proof/contract-bundle.ts
1288
+ var CONTRACT_VERSION = "0.6.0";
1289
+ var CONTRACT_MAJOR = 0;
1290
+ var CONTRACT_SUPPORTED_RANGE = ">=0.6.0 <0.7.0";
1291
+ function required(name, type) {
1292
+ return { name, type, optional: false };
1293
+ }
1294
+ function optional(name, type) {
1295
+ return { name, type, optional: true };
1296
+ }
1297
+ var CONTRACT_TYPES = [
1298
+ {
1299
+ name: "HandshakeRequest",
1300
+ fields: [
1301
+ required("clientId", "string"),
1302
+ required("keyId", "string"),
1303
+ required("nonce", "string"),
1304
+ required("issuedAtMillis", "integer")
1305
+ ]
1306
+ },
1307
+ {
1308
+ name: "HandshakeResponse",
1309
+ fields: [
1310
+ required("sessionId", "string"),
1311
+ required("expiresAtMillis", "integer")
1312
+ ]
1313
+ },
1314
+ {
1315
+ name: "EchoRequest",
1316
+ fields: [
1317
+ required("message", "string"),
1318
+ required("sequence", "integer")
1319
+ ]
1320
+ },
1321
+ {
1322
+ name: "EchoResponse",
1323
+ fields: [
1324
+ required("message", "string"),
1325
+ required("sequence", "integer"),
1326
+ required("serverTimeMillis", "integer")
1327
+ ]
1328
+ },
1329
+ {
1330
+ name: "ListItemsRequest",
1331
+ fields: [
1332
+ required("limit", "integer"),
1333
+ optional("cursor", "string")
1334
+ ]
1335
+ },
1336
+ {
1337
+ name: "Item",
1338
+ fields: [
1339
+ required("id", "string"),
1340
+ required("name", "string"),
1341
+ required("updatedAtMillis", "integer")
1342
+ ]
1343
+ },
1344
+ {
1345
+ name: "ListItemsResponse",
1346
+ fields: [
1347
+ required("items", "array<Item>"),
1348
+ optional("nextCursor", "string")
1349
+ ]
1350
+ },
1351
+ {
1352
+ name: "RegisterRequest",
1353
+ fields: [
1354
+ optional("email", "string"),
1355
+ optional("phone", "string"),
1356
+ required("verificationToken", "string"),
1357
+ required("password", "string"),
1358
+ required("publicKey", "string"),
1359
+ required("keyId", "string"),
1360
+ required("fingerprint", "string"),
1361
+ required("algorithm", "KeyAlgorithm")
1362
+ ]
1363
+ },
1364
+ {
1365
+ name: "RegisterResponse",
1366
+ fields: [
1367
+ required("userId", "string"),
1368
+ required("publicId", "string"),
1369
+ optional("email", "string"),
1370
+ optional("phone", "string")
1371
+ ]
1372
+ },
1373
+ {
1374
+ name: "LoginRequest",
1375
+ fields: [
1376
+ optional("email", "string"),
1377
+ optional("phone", "string"),
1378
+ required("password", "string"),
1379
+ required("publicKey", "string"),
1380
+ required("keyId", "string"),
1381
+ required("fingerprint", "string"),
1382
+ required("algorithm", "KeyAlgorithm"),
1383
+ optional("oldKeyId", "string")
1384
+ ]
1385
+ },
1386
+ {
1387
+ name: "LoginResponse",
1388
+ fields: [
1389
+ required("userId", "string"),
1390
+ required("publicId", "string"),
1391
+ optional("email", "string"),
1392
+ optional("phone", "string"),
1393
+ required("passwordChangeRequired", "boolean")
1394
+ ]
1395
+ },
1396
+ {
1397
+ name: "OauthNativeRequest",
1398
+ fields: [
1399
+ required("idToken", "string"),
1400
+ required("nonce", "string"),
1401
+ optional("accessToken", "string"),
1402
+ required("publicKey", "string"),
1403
+ required("keyId", "string"),
1404
+ required("fingerprint", "string"),
1405
+ required("algorithm", "KeyAlgorithm")
1406
+ ]
1407
+ },
1408
+ {
1409
+ name: "OauthNativeResponse",
1410
+ fields: [
1411
+ required("userId", "string"),
1412
+ required("keyId", "string"),
1413
+ required("isNewUser", "boolean")
1414
+ ]
1415
+ },
1416
+ {
1417
+ name: "RotateKeyRequest",
1418
+ fields: [
1419
+ required("publicKey", "string"),
1420
+ required("keyId", "string"),
1421
+ required("fingerprint", "string"),
1422
+ required("algorithm", "KeyAlgorithm")
1423
+ ]
1424
+ },
1425
+ {
1426
+ name: "RotateKeyResponse",
1427
+ fields: [
1428
+ required("success", "boolean"),
1429
+ required("keyId", "string")
1430
+ ]
1431
+ },
1432
+ {
1433
+ name: "ListKeysRequest",
1434
+ fields: [
1435
+ optional("includeRevoked", "boolean")
1436
+ ]
1437
+ },
1438
+ {
1439
+ name: "KeySummary",
1440
+ fields: [
1441
+ required("keyId", "string"),
1442
+ optional("deviceName", "string"),
1443
+ optional("platform", "string"),
1444
+ required("algorithm", "KeyAlgorithm"),
1445
+ required("fingerprintPrefix", "string"),
1446
+ required("createdAtMillis", "integer"),
1447
+ optional("lastUsedAtMillis", "integer"),
1448
+ optional("expiresAtMillis", "integer"),
1449
+ required("isExpired", "boolean"),
1450
+ required("isActive", "boolean"),
1451
+ optional("revokedAtMillis", "integer")
1452
+ ]
1453
+ },
1454
+ {
1455
+ name: "ListKeysResponse",
1456
+ fields: [
1457
+ required("keys", "array<KeySummary>")
1458
+ ]
1459
+ },
1460
+ {
1461
+ name: "RevokeKeyRequest",
1462
+ fields: [
1463
+ required("keyId", "string")
1464
+ ]
1465
+ },
1466
+ {
1467
+ name: "RevokeKeyResponse",
1468
+ fields: [
1469
+ required("keyId", "string"),
1470
+ required("selfRevoked", "boolean")
1471
+ ]
1472
+ },
1473
+ {
1474
+ name: "RevokeAllKeysRequest",
1475
+ fields: [
1476
+ optional("includeCurrent", "boolean")
1477
+ ]
1478
+ },
1479
+ {
1480
+ name: "RevokeAllKeysResponse",
1481
+ fields: [
1482
+ required("revokedCount", "integer"),
1483
+ required("currentKeyRevoked", "boolean")
1484
+ ]
1485
+ }
1486
+ ];
1487
+ var CONTRACT_ENUMS = [
1488
+ { name: "KeyAlgorithm", values: [...KEY_ALGORITHM] }
1489
+ ];
1490
+ var BUNDLE_FILENAME = "spfn-mobile-contract.json";
1491
+ var BUNDLE_REPO_PATH = `contracts/mobile/${BUNDLE_FILENAME}`;
1492
+
1493
+ // src/server/client-proof/wire-version.ts
1494
+ function readClientIdentity(headers) {
1495
+ const kind = headers.get(CLIENT_IDENTITY_HEADERS.kind);
1496
+ if (kind === null || !isClientKind(kind)) {
1497
+ return null;
1498
+ }
1499
+ return {
1500
+ kind,
1501
+ version: headers.get(CLIENT_IDENTITY_HEADERS.version),
1502
+ contractVersion: headers.get(CLIENT_IDENTITY_HEADERS.contractVersion)
1503
+ };
1504
+ }
1505
+ function isClientKind(value) {
1506
+ return CLIENT_KINDS.includes(value);
1507
+ }
1508
+ function isContractVersionSupported(clientVersion) {
1509
+ const client = parseVersion(clientVersion);
1510
+ if (client === null) {
1511
+ return false;
1512
+ }
1513
+ const server = parseVersion(CONTRACT_VERSION);
1514
+ if (server === null || client.major !== server.major) {
1515
+ return false;
1516
+ }
1517
+ return CONTRACT_MAJOR > 0 || client.minor === server.minor;
1518
+ }
1519
+ function parseVersion(raw) {
1520
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(raw);
1521
+ if (match === null) {
1522
+ return null;
1523
+ }
1524
+ return { major: Number(match[1]), minor: Number(match[2]) };
1525
+ }
1526
+ function judgeClientIdentity(identity) {
1527
+ if (identity === null || !isAppKind(identity.kind)) {
1528
+ return null;
1529
+ }
1530
+ if (identity.contractVersion === null) {
1531
+ return ClientProofRefusal.contractVersionMissing();
1532
+ }
1533
+ if (!isContractVersionSupported(identity.contractVersion)) {
1534
+ return ClientProofRefusal.contractVersionUnsupported();
1535
+ }
1536
+ return null;
1537
+ }
1538
+ function applyServerContractHeaders(headers) {
1539
+ headers.set(SERVER_CONTRACT_HEADERS.version, CONTRACT_VERSION);
1540
+ headers.set(SERVER_CONTRACT_HEADERS.supportedRange, CONTRACT_SUPPORTED_RANGE);
1541
+ }
1542
+ function serverContractHeaders() {
1543
+ return {
1544
+ [SERVER_CONTRACT_HEADERS.version]: CONTRACT_VERSION,
1545
+ [SERVER_CONTRACT_HEADERS.supportedRange]: CONTRACT_SUPPORTED_RANGE
1546
+ };
1547
+ }
1548
+
1254
1549
  // src/server/client-proof/dev-handler.ts
1255
1550
  var MAX_BODY_BYTES = 1 << 20;
1256
1551
  var HTTP_OK2 = 200;
@@ -1368,7 +1663,7 @@ function listItems(request) {
1368
1663
  function contractResponse(status, body) {
1369
1664
  return new Response(toArrayBuffer(body), {
1370
1665
  status,
1371
- headers: { "content-type": "application/json" }
1666
+ headers: { "content-type": "application/json", ...serverContractHeaders() }
1372
1667
  });
1373
1668
  }
1374
1669
  async function readBodyCapped(request) {
@@ -1419,7 +1714,8 @@ function createClientProofGuard(state, options = {}) {
1419
1714
  const bytes = admission.refusal.envelopeBytes(newHexId());
1420
1715
  const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
1421
1716
  return c.newResponse(buffer, admission.refusal.httpStatus, {
1422
- "content-type": "application/json"
1717
+ "content-type": "application/json",
1718
+ ...serverContractHeaders()
1423
1719
  });
1424
1720
  }
1425
1721
  c.set("clientType", "mobile");
@@ -1431,9 +1727,36 @@ function createClientProofGuard(state, options = {}) {
1431
1727
  return void 0;
1432
1728
  };
1433
1729
  }
1730
+
1731
+ // src/server/client-proof/version-middleware.ts
1732
+ var CLIENT_IDENTITY_CONTEXT_KEY = "clientIdentity";
1733
+ function createClientVersionMiddleware() {
1734
+ return async (c, next) => {
1735
+ const identity = readClientIdentity(c.req.raw.headers);
1736
+ const refusal = judgeClientIdentity(identity);
1737
+ if (refusal !== null) {
1738
+ const bytes = refusal.envelopeBytes(newHexId());
1739
+ const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
1740
+ const response = c.newResponse(buffer, refusal.httpStatus, {
1741
+ "content-type": "application/json"
1742
+ });
1743
+ applyServerContractHeaders(response.headers);
1744
+ return response;
1745
+ }
1746
+ if (identity !== null) {
1747
+ c.set(CLIENT_IDENTITY_CONTEXT_KEY, identity);
1748
+ }
1749
+ await next();
1750
+ applyServerContractHeaders(c.res.headers);
1751
+ return void 0;
1752
+ };
1753
+ }
1434
1754
  export {
1435
1755
  ABSENT_BODY_SHA256,
1436
1756
  AUTH_SURFACE_OPERATIONS,
1757
+ CLIENT_IDENTITY_CONTEXT_KEY,
1758
+ CLIENT_IDENTITY_HEADERS,
1759
+ CLIENT_KINDS,
1437
1760
  CLIENT_PROOF_CONTENT_TYPE,
1438
1761
  CLIENT_PROOF_HEADERS,
1439
1762
  CLIENT_PROOF_PROFILE,
@@ -1454,12 +1777,15 @@ export {
1454
1777
  PROOF_SIGNATURE_HEX_LENGTH,
1455
1778
  ProofInputError,
1456
1779
  RedisReplayStore,
1780
+ SERVER_CONTRACT_HEADERS,
1457
1781
  TestClock,
1458
1782
  admitClientProofRequest,
1783
+ applyServerContractHeaders,
1459
1784
  canonicalProofInput,
1460
1785
  configureClientProofReplayStore,
1461
1786
  createClientProofDevHandler,
1462
1787
  createClientProofGuard,
1788
+ createClientVersionMiddleware,
1463
1789
  decodeEchoRequest,
1464
1790
  decodeHandshakeRequest,
1465
1791
  decodeListItemsRequest,
@@ -1468,13 +1794,18 @@ export {
1468
1794
  encodeHandshakeResponse,
1469
1795
  encodeListItemsResponse,
1470
1796
  getClientProofReplayStore,
1797
+ isAppKind,
1471
1798
  isCanonicalBytes,
1799
+ isContractVersionSupported,
1472
1800
  isRequestContentType,
1801
+ judgeClientIdentity,
1473
1802
  newHexId,
1474
1803
  parseCanonicalJson,
1475
1804
  parseClientProofPublicKey,
1805
+ readClientIdentity,
1476
1806
  readCredentials,
1477
1807
  replayLedgerKey,
1808
+ serverContractHeaders,
1478
1809
  sha256Hex,
1479
1810
  signClientProof,
1480
1811
  systemClock,