@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.
@@ -2667,6 +2667,182 @@ function matchRoute(table, method, pathname) {
2667
2667
  return null;
2668
2668
  }
2669
2669
 
2670
+ // src/engine/attest.ts
2671
+ var HEADER_DEVICE_ID = "x-palbase-device-id";
2672
+ var HEADER_PAYLOAD = "x-palbase-attest-payload";
2673
+ var HEADER_SIGNATURE = "x-palbase-attest-signature";
2674
+ var HEADER_CHALLENGE = "x-palbase-attest-challenge";
2675
+ var HEADER_CAPABLE = "x-palbase-attest-capable";
2676
+ var HEADER_INSTALLATION = "x-palbase-installation";
2677
+ var HEADER_ANCHOR = "x-palbase-installation-anchor";
2678
+ var DEFAULT_POLICY_TTL_MS = 3e4;
2679
+ var DEFAULT_REQUEST_TIMEOUT_MS = 2e3;
2680
+ async function bodyDigest(body) {
2681
+ if (!body || body.byteLength === 0) return "";
2682
+ const bytes = new Uint8Array(new ArrayBuffer(body.byteLength));
2683
+ bytes.set(body);
2684
+ const buf = await crypto.subtle.digest("SHA-256", bytes);
2685
+ return btoa(String.fromCharCode(...new Uint8Array(buf)));
2686
+ }
2687
+ function readCapable(req) {
2688
+ const v = req.headers.get(HEADER_CAPABLE);
2689
+ return v === "yes" || v === "no" ? v : "unknown";
2690
+ }
2691
+ function createAttestGate(deps) {
2692
+ const doFetch = deps.fetchImpl ?? fetch;
2693
+ const ttl = deps.policyTtlMs ?? DEFAULT_POLICY_TTL_MS;
2694
+ const policyTimeoutMs = deps.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
2695
+ let cachedMode = "off";
2696
+ let cachedAt = -Infinity;
2697
+ async function mode() {
2698
+ if (Date.now() - cachedAt < ttl) return cachedMode;
2699
+ try {
2700
+ const res = await doFetch(`${deps.moduleBaseUrl}/admin/attest/devices/attest-policy`, {
2701
+ headers: { apikey: deps.serviceRoleKey },
2702
+ signal: AbortSignal.timeout(policyTimeoutMs)
2703
+ });
2704
+ if (!res.ok) throw new Error(`attest-policy ${res.status}`);
2705
+ const body = await res.json();
2706
+ cachedMode = body.mode === "monitor" || body.mode === "enforce" ? body.mode : "off";
2707
+ cachedAt = Date.now();
2708
+ } catch {
2709
+ }
2710
+ return cachedMode;
2711
+ }
2712
+ return async function gate(req, body, _ctx) {
2713
+ const m = await mode();
2714
+ if (m === "off") {
2715
+ return { outcome: "pass", reason: "", device: null, observation: null, nextChallenge: null };
2716
+ }
2717
+ const deviceId = req.headers.get(HEADER_DEVICE_ID) ?? "";
2718
+ const payload = req.headers.get(HEADER_PAYLOAD) ?? "";
2719
+ const signature = req.headers.get(HEADER_SIGNATURE) ?? "";
2720
+ const hasEvidence = deviceId !== "" && payload !== "" && signature !== "";
2721
+ const observation = {
2722
+ capable: readCapable(req),
2723
+ attested: false,
2724
+ installationId: req.headers.get(HEADER_INSTALLATION) || null
2725
+ };
2726
+ if (m === "monitor") {
2727
+ observation.attested = hasEvidence;
2728
+ return { outcome: "pass", reason: "", device: null, observation, nextChallenge: null };
2729
+ }
2730
+ const url = new URL(req.url);
2731
+ let verdict;
2732
+ try {
2733
+ const res = await doFetch(`${deps.moduleBaseUrl}/admin/attest/devices/assert`, {
2734
+ method: "POST",
2735
+ headers: { apikey: deps.serviceRoleKey, "content-type": "application/json" },
2736
+ // A HUNG palauth must not become a hung tenant. Without this, a
2737
+ // process that accepts the connection and never answers holds every
2738
+ // request open until the client gives up — the gate would take the
2739
+ // whole stack down while claiming to protect it.
2740
+ signal: AbortSignal.timeout(policyTimeoutMs),
2741
+ body: JSON.stringify({
2742
+ installation_id: req.headers.get(HEADER_ANCHOR) ?? "",
2743
+ device_id: deviceId,
2744
+ challenge: req.headers.get(HEADER_CHALLENGE) ?? "",
2745
+ payload,
2746
+ signature,
2747
+ method: req.method,
2748
+ // pathname + search, NOT pathname alone.
2749
+ //
2750
+ // The client signs the path string it was given, and that string can
2751
+ // carry a query — the transport splits and reattaches one on purpose.
2752
+ // Recomputing from pathname alone would drop the query from the
2753
+ // signed definition, which breaks it BOTH ways: an attested call
2754
+ // carrying a query would fail to verify, and a captured assertion
2755
+ // could be replayed against the same route with different query
2756
+ // parameters, which is exactly the substitution the binding exists to
2757
+ // prevent.
2758
+ path: url.pathname + url.search,
2759
+ body_sha256: await bodyDigest(body)
2760
+ })
2761
+ });
2762
+ if (!res.ok) throw new Error(`assert ${res.status}`);
2763
+ verdict = await res.json();
2764
+ } catch {
2765
+ return {
2766
+ outcome: "reject",
2767
+ reason: "app_attest_required",
2768
+ device: null,
2769
+ observation,
2770
+ nextChallenge: null
2771
+ };
2772
+ }
2773
+ if (!verdict.ok) {
2774
+ const reason2 = verdict.reason === "assertion_invalid" || verdict.reason === "assertion_stale" ? verdict.reason : "app_attest_required";
2775
+ return {
2776
+ outcome: "reject",
2777
+ reason: reason2,
2778
+ device: null,
2779
+ observation,
2780
+ nextChallenge: verdict.next_challenge || null
2781
+ };
2782
+ }
2783
+ observation.attested = true;
2784
+ return {
2785
+ outcome: "pass",
2786
+ reason: "",
2787
+ device: {
2788
+ installationId: "",
2789
+ authDeviceId: deviceId,
2790
+ platform: "ios",
2791
+ assurance: "attested"
2792
+ },
2793
+ observation,
2794
+ nextChallenge: verdict.next_challenge || null
2795
+ };
2796
+ };
2797
+ }
2798
+ function createObservationSink(deps) {
2799
+ const doFetch = deps.fetchImpl ?? fetch;
2800
+ const maxBatch = deps.maxBatch ?? 1e3;
2801
+ const intervalMs = deps.intervalMs ?? 1e4;
2802
+ let buffer = [];
2803
+ let inFlight = Promise.resolve();
2804
+ const timer = setInterval(() => {
2805
+ void flush();
2806
+ }, intervalMs);
2807
+ timer.unref?.();
2808
+ function bucketsOf(batch) {
2809
+ const counts = { capable: 0, incapable: 0, unknown: 0, no_sdk: 0 };
2810
+ for (const o of batch) {
2811
+ if (o.attested || o.capable === "yes") counts.capable += 1;
2812
+ else if (o.capable === "no") counts.incapable += 1;
2813
+ else if (o.installationId) counts.unknown += 1;
2814
+ else counts.no_sdk += 1;
2815
+ }
2816
+ return counts;
2817
+ }
2818
+ async function flush() {
2819
+ if (buffer.length === 0) return;
2820
+ const batch = buffer;
2821
+ buffer = [];
2822
+ inFlight = (async () => {
2823
+ try {
2824
+ await doFetch(`${deps.moduleBaseUrl}/admin/attest/devices/observations`, {
2825
+ method: "POST",
2826
+ headers: { apikey: deps.serviceRoleKey, "content-type": "application/json" },
2827
+ body: JSON.stringify(bucketsOf(batch))
2828
+ });
2829
+ } catch {
2830
+ }
2831
+ })();
2832
+ await inFlight;
2833
+ }
2834
+ return {
2835
+ record(o) {
2836
+ buffer.push(o);
2837
+ if (buffer.length >= maxBatch) void flush();
2838
+ },
2839
+ flush,
2840
+ stop() {
2841
+ clearInterval(timer);
2842
+ }
2843
+ };
2844
+ }
2845
+
2670
2846
  // src/engine/upload.ts
2671
2847
  var AUTHORIZE_PATH = "/__palbase/upload/authorize";
2672
2848
  var SIGNATURE_HEADER = "x-palbase-upload-signature";
@@ -2759,6 +2935,7 @@ function makeSseWriter(opts) {
2759
2935
  }
2760
2936
 
2761
2937
  // src/engine/index.ts
2938
+ var MAX_BODY_BYTES = 10 * 1024 * 1024;
2762
2939
  var JSON_HEADERS = { "content-type": "application/json" };
2763
2940
  function fieldErrors(err) {
2764
2941
  return err.issues.map((i) => ({ field: i.path.join("."), message: i.message }));
@@ -2819,6 +2996,14 @@ async function createApp(opts) {
2819
2996
  const sql = opts.sql ?? await defaultSqlDriver(config);
2820
2997
  await sql.unsafe("select 1");
2821
2998
  const auth = new AuthVerifier({ jwksUrl: config.authJwksUrl, issuer: config.authIssuer });
2999
+ const attestGate = createAttestGate({
3000
+ moduleBaseUrl: config.moduleBaseUrl,
3001
+ serviceRoleKey: config.serviceRoleKey
3002
+ });
3003
+ const observations = createObservationSink({
3004
+ moduleBaseUrl: config.moduleBaseUrl,
3005
+ serviceRoleKey: config.serviceRoleKey
3006
+ });
2822
3007
  const limiter = new RateLimiter();
2823
3008
  const cache = opts.cache ?? makeMemoryCache();
2824
3009
  const log = opts.logger ?? console;
@@ -2900,6 +3085,8 @@ async function createApp(opts) {
2900
3085
  const hit = matchRoute(routes, req.method, url.pathname);
2901
3086
  if (!hit) return envelope("not_found", "No route matches this method and path", 404, requestId);
2902
3087
  const { meta } = hit.entry;
3088
+ let attestedDevice = null;
3089
+ let attestChallengeOut = null;
2903
3090
  const spec = effectiveAuth(meta.options?.auth, hit.entry.controllerAuth);
2904
3091
  const claims = await auth.verify(req.headers.get("authorization"));
2905
3092
  if (spec.required && !claims) {
@@ -2912,6 +3099,67 @@ async function createApp(opts) {
2912
3099
  if (claims && spec.verifiedEmail && claims.email_verified !== true) {
2913
3100
  return envelope("email_not_verified", "A verified email address is required", 403, requestId);
2914
3101
  }
3102
+ let parsedBody;
3103
+ let bodyRead = false;
3104
+ let rawBody = null;
3105
+ if (req.method !== "GET" && req.method !== "HEAD") {
3106
+ const declared = Number(req.headers.get("content-length") ?? "");
3107
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
3108
+ return new Response(
3109
+ JSON.stringify({
3110
+ error: "payload_too_large",
3111
+ error_description: `Request body exceeds ${MAX_BODY_BYTES} bytes`,
3112
+ status: 413,
3113
+ request_id: requestId
3114
+ }),
3115
+ { status: 413, headers: JSON_HEADERS }
3116
+ );
3117
+ }
3118
+ const buf = await req.arrayBuffer().catch(() => null);
3119
+ if (buf && buf.byteLength > MAX_BODY_BYTES) {
3120
+ return new Response(
3121
+ JSON.stringify({
3122
+ error: "payload_too_large",
3123
+ error_description: `Request body exceeds ${MAX_BODY_BYTES} bytes`,
3124
+ status: 413,
3125
+ request_id: requestId
3126
+ }),
3127
+ { status: 413, headers: JSON_HEADERS }
3128
+ );
3129
+ }
3130
+ if (buf && buf.byteLength > 0) rawBody = new Uint8Array(buf);
3131
+ }
3132
+ const readParsedBody = () => {
3133
+ if (bodyRead) return parsedBody;
3134
+ bodyRead = true;
3135
+ if (!rawBody) {
3136
+ parsedBody = {};
3137
+ return parsedBody;
3138
+ }
3139
+ try {
3140
+ parsedBody = JSON.parse(new TextDecoder().decode(rawBody));
3141
+ } catch {
3142
+ parsedBody = {};
3143
+ }
3144
+ return parsedBody;
3145
+ };
3146
+ const attest = await attestGate(req, rawBody, { userId });
3147
+ if (attest.observation) observations.record(attest.observation);
3148
+ if (attest.outcome === "reject") {
3149
+ const headers = { ...JSON_HEADERS };
3150
+ if (attest.nextChallenge) headers["x-palbase-attest-challenge"] = attest.nextChallenge;
3151
+ return new Response(
3152
+ JSON.stringify({
3153
+ error: attest.reason,
3154
+ error_description: "This endpoint requires a verified app instance",
3155
+ status: 401,
3156
+ request_id: requestId
3157
+ }),
3158
+ { status: 401, headers }
3159
+ );
3160
+ }
3161
+ if (attest.device && claims) attestedDevice = attest.device;
3162
+ attestChallengeOut = attest.nextChallenge;
2915
3163
  const retryAfter = limiter.check(
2916
3164
  meta.options?.rateLimit,
2917
3165
  RateLimiter.key(hit.entry.id, userId, req.headers),
@@ -2931,8 +3179,6 @@ async function createApp(opts) {
2931
3179
  }
2932
3180
  let completionUploadId = null;
2933
3181
  const args = [];
2934
- let parsedBody;
2935
- let bodyRead = false;
2936
3182
  let sseEnqueue = null;
2937
3183
  let sseSettle = async () => {
2938
3184
  };
@@ -2943,10 +3189,7 @@ async function createApp(opts) {
2943
3189
  for (const p of meta.params ?? []) {
2944
3190
  switch (p.kind) {
2945
3191
  case "body": {
2946
- if (!bodyRead) {
2947
- parsedBody = await req.json().catch(() => ({}));
2948
- bodyRead = true;
2949
- }
3192
+ readParsedBody();
2950
3193
  const r = p.schema.safeParse(parsedBody);
2951
3194
  if (!r.success) {
2952
3195
  return envelope("bad_request", "Request body failed validation", 400, requestId, {
@@ -2991,7 +3234,12 @@ async function createApp(opts) {
2991
3234
  email: claims.email,
2992
3235
  role: claims.role,
2993
3236
  emailVerified: claims.email_verified === true,
2994
- metadata: claims.metadata ?? {}
3237
+ metadata: claims.metadata ?? {},
3238
+ // Server-owned and only ever filled from a verdict palauth
3239
+ // returned. Never from a raw header, never from JWT metadata —
3240
+ // the type has said so since it was written and this is the
3241
+ // first writer.
3242
+ device: attestedDevice
2995
3243
  } : null;
2996
3244
  break;
2997
3245
  case "uploadedObject": {
@@ -3003,10 +3251,7 @@ async function createApp(opts) {
3003
3251
  requestId
3004
3252
  );
3005
3253
  }
3006
- if (!bodyRead) {
3007
- parsedBody = await req.json().catch(() => ({}));
3008
- bodyRead = true;
3009
- }
3254
+ readParsedBody();
3010
3255
  const envelopeIn = parsedBody;
3011
3256
  if (!envelopeIn?.uploadedObject) {
3012
3257
  return envelope("bad_request", "the completion call carried no uploaded object", 400, requestId);
@@ -3189,7 +3434,10 @@ async function createApp(opts) {
3189
3434
  contentType: JSON_HEADERS["content-type"] ?? "application/json"
3190
3435
  });
3191
3436
  }
3192
- return new Response(payload, { status: 200, headers: JSON_HEADERS });
3437
+ return new Response(payload, {
3438
+ status: 200,
3439
+ headers: attestChallengeOut ? { ...JSON_HEADERS, "x-palbase-attest-challenge": attestChallengeOut } : JSON_HEADERS
3440
+ });
3193
3441
  } catch (err) {
3194
3442
  await db.rollback(err);
3195
3443
  if (isHttpError(err)) {