@palbase/backend 25.0.4 → 25.1.0

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.
@@ -2689,6 +2689,182 @@ function matchRoute(table, method, pathname) {
2689
2689
  return null;
2690
2690
  }
2691
2691
 
2692
+ // src/engine/attest.ts
2693
+ var HEADER_DEVICE_ID = "x-palbase-device-id";
2694
+ var HEADER_PAYLOAD = "x-palbase-attest-payload";
2695
+ var HEADER_SIGNATURE = "x-palbase-attest-signature";
2696
+ var HEADER_CHALLENGE = "x-palbase-attest-challenge";
2697
+ var HEADER_CAPABLE = "x-palbase-attest-capable";
2698
+ var HEADER_INSTALLATION = "x-palbase-installation";
2699
+ var HEADER_ANCHOR = "x-palbase-installation-anchor";
2700
+ var DEFAULT_POLICY_TTL_MS = 3e4;
2701
+ var DEFAULT_REQUEST_TIMEOUT_MS = 2e3;
2702
+ async function bodyDigest(body) {
2703
+ if (!body || body.byteLength === 0) return "";
2704
+ const bytes = new Uint8Array(new ArrayBuffer(body.byteLength));
2705
+ bytes.set(body);
2706
+ const buf = await crypto.subtle.digest("SHA-256", bytes);
2707
+ return btoa(String.fromCharCode(...new Uint8Array(buf)));
2708
+ }
2709
+ function readCapable(req) {
2710
+ const v = req.headers.get(HEADER_CAPABLE);
2711
+ return v === "yes" || v === "no" ? v : "unknown";
2712
+ }
2713
+ function createAttestGate(deps) {
2714
+ const doFetch = deps.fetchImpl ?? fetch;
2715
+ const ttl = deps.policyTtlMs ?? DEFAULT_POLICY_TTL_MS;
2716
+ const policyTimeoutMs = deps.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
2717
+ let cachedMode = "off";
2718
+ let cachedAt = -Infinity;
2719
+ async function mode() {
2720
+ if (Date.now() - cachedAt < ttl) return cachedMode;
2721
+ try {
2722
+ const res = await doFetch(`${deps.moduleBaseUrl}/admin/attest/devices/attest-policy`, {
2723
+ headers: { apikey: deps.serviceRoleKey },
2724
+ signal: AbortSignal.timeout(policyTimeoutMs)
2725
+ });
2726
+ if (!res.ok) throw new Error(`attest-policy ${res.status}`);
2727
+ const body = await res.json();
2728
+ cachedMode = body.mode === "monitor" || body.mode === "enforce" ? body.mode : "off";
2729
+ cachedAt = Date.now();
2730
+ } catch {
2731
+ }
2732
+ return cachedMode;
2733
+ }
2734
+ return async function gate(req, body, _ctx) {
2735
+ const m = await mode();
2736
+ if (m === "off") {
2737
+ return { outcome: "pass", reason: "", device: null, observation: null, nextChallenge: null };
2738
+ }
2739
+ const deviceId = req.headers.get(HEADER_DEVICE_ID) ?? "";
2740
+ const payload = req.headers.get(HEADER_PAYLOAD) ?? "";
2741
+ const signature = req.headers.get(HEADER_SIGNATURE) ?? "";
2742
+ const hasEvidence = deviceId !== "" && payload !== "" && signature !== "";
2743
+ const observation = {
2744
+ capable: readCapable(req),
2745
+ attested: false,
2746
+ installationId: req.headers.get(HEADER_INSTALLATION) || null
2747
+ };
2748
+ if (m === "monitor") {
2749
+ observation.attested = hasEvidence;
2750
+ return { outcome: "pass", reason: "", device: null, observation, nextChallenge: null };
2751
+ }
2752
+ const url = new URL(req.url);
2753
+ let verdict;
2754
+ try {
2755
+ const res = await doFetch(`${deps.moduleBaseUrl}/admin/attest/devices/assert`, {
2756
+ method: "POST",
2757
+ headers: { apikey: deps.serviceRoleKey, "content-type": "application/json" },
2758
+ // A HUNG palauth must not become a hung tenant. Without this, a
2759
+ // process that accepts the connection and never answers holds every
2760
+ // request open until the client gives up — the gate would take the
2761
+ // whole stack down while claiming to protect it.
2762
+ signal: AbortSignal.timeout(policyTimeoutMs),
2763
+ body: JSON.stringify({
2764
+ installation_id: req.headers.get(HEADER_ANCHOR) ?? "",
2765
+ device_id: deviceId,
2766
+ challenge: req.headers.get(HEADER_CHALLENGE) ?? "",
2767
+ payload,
2768
+ signature,
2769
+ method: req.method,
2770
+ // pathname + search, NOT pathname alone.
2771
+ //
2772
+ // The client signs the path string it was given, and that string can
2773
+ // carry a query — the transport splits and reattaches one on purpose.
2774
+ // Recomputing from pathname alone would drop the query from the
2775
+ // signed definition, which breaks it BOTH ways: an attested call
2776
+ // carrying a query would fail to verify, and a captured assertion
2777
+ // could be replayed against the same route with different query
2778
+ // parameters, which is exactly the substitution the binding exists to
2779
+ // prevent.
2780
+ path: url.pathname + url.search,
2781
+ body_sha256: await bodyDigest(body)
2782
+ })
2783
+ });
2784
+ if (!res.ok) throw new Error(`assert ${res.status}`);
2785
+ verdict = await res.json();
2786
+ } catch {
2787
+ return {
2788
+ outcome: "reject",
2789
+ reason: "app_attest_required",
2790
+ device: null,
2791
+ observation,
2792
+ nextChallenge: null
2793
+ };
2794
+ }
2795
+ if (!verdict.ok) {
2796
+ const reason2 = verdict.reason === "assertion_invalid" || verdict.reason === "assertion_stale" ? verdict.reason : "app_attest_required";
2797
+ return {
2798
+ outcome: "reject",
2799
+ reason: reason2,
2800
+ device: null,
2801
+ observation,
2802
+ nextChallenge: verdict.next_challenge || null
2803
+ };
2804
+ }
2805
+ observation.attested = true;
2806
+ return {
2807
+ outcome: "pass",
2808
+ reason: "",
2809
+ device: {
2810
+ installationId: "",
2811
+ authDeviceId: deviceId,
2812
+ platform: "ios",
2813
+ assurance: "attested"
2814
+ },
2815
+ observation,
2816
+ nextChallenge: verdict.next_challenge || null
2817
+ };
2818
+ };
2819
+ }
2820
+ function createObservationSink(deps) {
2821
+ const doFetch = deps.fetchImpl ?? fetch;
2822
+ const maxBatch = deps.maxBatch ?? 1e3;
2823
+ const intervalMs = deps.intervalMs ?? 1e4;
2824
+ let buffer = [];
2825
+ let inFlight = Promise.resolve();
2826
+ const timer = setInterval(() => {
2827
+ void flush();
2828
+ }, intervalMs);
2829
+ timer.unref?.();
2830
+ function bucketsOf(batch) {
2831
+ const counts = { capable: 0, incapable: 0, unknown: 0, no_sdk: 0 };
2832
+ for (const o of batch) {
2833
+ if (o.attested || o.capable === "yes") counts.capable += 1;
2834
+ else if (o.capable === "no") counts.incapable += 1;
2835
+ else if (o.installationId) counts.unknown += 1;
2836
+ else counts.no_sdk += 1;
2837
+ }
2838
+ return counts;
2839
+ }
2840
+ async function flush() {
2841
+ if (buffer.length === 0) return;
2842
+ const batch = buffer;
2843
+ buffer = [];
2844
+ inFlight = (async () => {
2845
+ try {
2846
+ await doFetch(`${deps.moduleBaseUrl}/admin/attest/devices/observations`, {
2847
+ method: "POST",
2848
+ headers: { apikey: deps.serviceRoleKey, "content-type": "application/json" },
2849
+ body: JSON.stringify(bucketsOf(batch))
2850
+ });
2851
+ } catch {
2852
+ }
2853
+ })();
2854
+ await inFlight;
2855
+ }
2856
+ return {
2857
+ record(o) {
2858
+ buffer.push(o);
2859
+ if (buffer.length >= maxBatch) void flush();
2860
+ },
2861
+ flush,
2862
+ stop() {
2863
+ clearInterval(timer);
2864
+ }
2865
+ };
2866
+ }
2867
+
2692
2868
  // src/engine/upload.ts
2693
2869
  var AUTHORIZE_PATH = "/__palbase/upload/authorize";
2694
2870
  var SIGNATURE_HEADER = "x-palbase-upload-signature";
@@ -2842,6 +3018,7 @@ function installEgressFence(policy) {
2842
3018
  }
2843
3019
 
2844
3020
  // src/engine/index.ts
3021
+ var MAX_BODY_BYTES = 10 * 1024 * 1024;
2845
3022
  var JSON_HEADERS = { "content-type": "application/json" };
2846
3023
  function fieldErrors(err) {
2847
3024
  return err.issues.map((i) => ({ field: i.path.join("."), message: i.message }));
@@ -2902,6 +3079,14 @@ async function createApp(opts) {
2902
3079
  const sql = opts.sql ?? await defaultSqlDriver(config);
2903
3080
  await sql.unsafe("select 1");
2904
3081
  const auth = new AuthVerifier({ jwksUrl: config.authJwksUrl, issuer: config.authIssuer });
3082
+ const attestGate = createAttestGate({
3083
+ moduleBaseUrl: config.moduleBaseUrl,
3084
+ serviceRoleKey: config.serviceRoleKey
3085
+ });
3086
+ const observations = createObservationSink({
3087
+ moduleBaseUrl: config.moduleBaseUrl,
3088
+ serviceRoleKey: config.serviceRoleKey
3089
+ });
2905
3090
  const limiter = new RateLimiter();
2906
3091
  const cache = opts.cache ?? makeMemoryCache();
2907
3092
  const log = opts.logger ?? console;
@@ -2983,6 +3168,8 @@ async function createApp(opts) {
2983
3168
  const hit = matchRoute(routes, req.method, url.pathname);
2984
3169
  if (!hit) return envelope("not_found", "No route matches this method and path", 404, requestId);
2985
3170
  const { meta } = hit.entry;
3171
+ let attestedDevice = null;
3172
+ let attestChallengeOut = null;
2986
3173
  const spec = effectiveAuth(meta.options?.auth, hit.entry.controllerAuth);
2987
3174
  const claims = await auth.verify(req.headers.get("authorization"));
2988
3175
  if (spec.required && !claims) {
@@ -2995,6 +3182,67 @@ async function createApp(opts) {
2995
3182
  if (claims && spec.verifiedEmail && claims.email_verified !== true) {
2996
3183
  return envelope("email_not_verified", "A verified email address is required", 403, requestId);
2997
3184
  }
3185
+ let parsedBody;
3186
+ let bodyRead = false;
3187
+ let rawBody = null;
3188
+ if (req.method !== "GET" && req.method !== "HEAD") {
3189
+ const declared = Number(req.headers.get("content-length") ?? "");
3190
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
3191
+ return new Response(
3192
+ JSON.stringify({
3193
+ error: "payload_too_large",
3194
+ error_description: `Request body exceeds ${MAX_BODY_BYTES} bytes`,
3195
+ status: 413,
3196
+ request_id: requestId
3197
+ }),
3198
+ { status: 413, headers: JSON_HEADERS }
3199
+ );
3200
+ }
3201
+ const buf = await req.arrayBuffer().catch(() => null);
3202
+ if (buf && buf.byteLength > MAX_BODY_BYTES) {
3203
+ return new Response(
3204
+ JSON.stringify({
3205
+ error: "payload_too_large",
3206
+ error_description: `Request body exceeds ${MAX_BODY_BYTES} bytes`,
3207
+ status: 413,
3208
+ request_id: requestId
3209
+ }),
3210
+ { status: 413, headers: JSON_HEADERS }
3211
+ );
3212
+ }
3213
+ if (buf && buf.byteLength > 0) rawBody = new Uint8Array(buf);
3214
+ }
3215
+ const readParsedBody = () => {
3216
+ if (bodyRead) return parsedBody;
3217
+ bodyRead = true;
3218
+ if (!rawBody) {
3219
+ parsedBody = {};
3220
+ return parsedBody;
3221
+ }
3222
+ try {
3223
+ parsedBody = JSON.parse(new TextDecoder().decode(rawBody));
3224
+ } catch {
3225
+ parsedBody = {};
3226
+ }
3227
+ return parsedBody;
3228
+ };
3229
+ const attest = await attestGate(req, rawBody, { userId });
3230
+ if (attest.observation) observations.record(attest.observation);
3231
+ if (attest.outcome === "reject") {
3232
+ const headers = { ...JSON_HEADERS };
3233
+ if (attest.nextChallenge) headers["x-palbase-attest-challenge"] = attest.nextChallenge;
3234
+ return new Response(
3235
+ JSON.stringify({
3236
+ error: attest.reason,
3237
+ error_description: "This endpoint requires a verified app instance",
3238
+ status: 401,
3239
+ request_id: requestId
3240
+ }),
3241
+ { status: 401, headers }
3242
+ );
3243
+ }
3244
+ if (attest.device && claims) attestedDevice = attest.device;
3245
+ attestChallengeOut = attest.nextChallenge;
2998
3246
  const retryAfter = limiter.check(
2999
3247
  meta.options?.rateLimit,
3000
3248
  RateLimiter.key(hit.entry.id, userId, req.headers),
@@ -3014,8 +3262,6 @@ async function createApp(opts) {
3014
3262
  }
3015
3263
  let completionUploadId = null;
3016
3264
  const args = [];
3017
- let parsedBody;
3018
- let bodyRead = false;
3019
3265
  let sseEnqueue = null;
3020
3266
  let sseSettle = async () => {
3021
3267
  };
@@ -3026,10 +3272,7 @@ async function createApp(opts) {
3026
3272
  for (const p of meta.params ?? []) {
3027
3273
  switch (p.kind) {
3028
3274
  case "body": {
3029
- if (!bodyRead) {
3030
- parsedBody = await req.json().catch(() => ({}));
3031
- bodyRead = true;
3032
- }
3275
+ readParsedBody();
3033
3276
  const r = p.schema.safeParse(parsedBody);
3034
3277
  if (!r.success) {
3035
3278
  return envelope("bad_request", "Request body failed validation", 400, requestId, {
@@ -3074,7 +3317,12 @@ async function createApp(opts) {
3074
3317
  email: claims.email,
3075
3318
  role: claims.role,
3076
3319
  emailVerified: claims.email_verified === true,
3077
- metadata: claims.metadata ?? {}
3320
+ metadata: claims.metadata ?? {},
3321
+ // Server-owned and only ever filled from a verdict palauth
3322
+ // returned. Never from a raw header, never from JWT metadata —
3323
+ // the type has said so since it was written and this is the
3324
+ // first writer.
3325
+ device: attestedDevice
3078
3326
  } : null;
3079
3327
  break;
3080
3328
  case "uploadedObject": {
@@ -3086,10 +3334,7 @@ async function createApp(opts) {
3086
3334
  requestId
3087
3335
  );
3088
3336
  }
3089
- if (!bodyRead) {
3090
- parsedBody = await req.json().catch(() => ({}));
3091
- bodyRead = true;
3092
- }
3337
+ readParsedBody();
3093
3338
  const envelopeIn = parsedBody;
3094
3339
  if (!envelopeIn?.uploadedObject) {
3095
3340
  return envelope("bad_request", "the completion call carried no uploaded object", 400, requestId);
@@ -3272,7 +3517,10 @@ async function createApp(opts) {
3272
3517
  contentType: JSON_HEADERS["content-type"] ?? "application/json"
3273
3518
  });
3274
3519
  }
3275
- return new Response(payload, { status: 200, headers: JSON_HEADERS });
3520
+ return new Response(payload, {
3521
+ status: 200,
3522
+ headers: attestChallengeOut ? { ...JSON_HEADERS, "x-palbase-attest-challenge": attestChallengeOut } : JSON_HEADERS
3523
+ });
3276
3524
  } catch (err) {
3277
3525
  await db.rollback(err);
3278
3526
  if (isHttpError(err)) {