@palbase/backend 25.0.3 → 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.
@@ -2,11 +2,14 @@ import {
2
2
  __requestALS,
3
3
  __runStartHooks,
4
4
  __runWithRuntime
5
- } from "./chunk-XJ2RSHEU.js";
5
+ } from "./chunk-CRQKCRGF.js";
6
6
  import {
7
7
  assertUsableFilter,
8
8
  assertUsableWriteValues
9
9
  } from "./chunk-XABBC7JP.js";
10
+ import {
11
+ qualifiedTableKey
12
+ } from "./chunk-CJSKYY76.js";
10
13
  import {
11
14
  UniqueViolation,
12
15
  assertZeroArgConstructor,
@@ -1491,9 +1494,6 @@ function setSchema(schemas) {
1491
1494
  }
1492
1495
  currentSchema = { tables };
1493
1496
  }
1494
- function qualifiedTableKey(schema, table) {
1495
- return schema === "" || schema === "public" ? table : `${schema}.${table}`;
1496
- }
1497
1497
  function withTables(ops, schema = currentSchema) {
1498
1498
  const bind = (key) => ({
1499
1499
  insert: (data) => ops.insert(key, data),
@@ -1819,6 +1819,182 @@ function matchRoute(table, method, pathname) {
1819
1819
  return null;
1820
1820
  }
1821
1821
 
1822
+ // src/engine/attest.ts
1823
+ var HEADER_DEVICE_ID = "x-palbase-device-id";
1824
+ var HEADER_PAYLOAD = "x-palbase-attest-payload";
1825
+ var HEADER_SIGNATURE = "x-palbase-attest-signature";
1826
+ var HEADER_CHALLENGE = "x-palbase-attest-challenge";
1827
+ var HEADER_CAPABLE = "x-palbase-attest-capable";
1828
+ var HEADER_INSTALLATION = "x-palbase-installation";
1829
+ var HEADER_ANCHOR = "x-palbase-installation-anchor";
1830
+ var DEFAULT_POLICY_TTL_MS = 3e4;
1831
+ var DEFAULT_REQUEST_TIMEOUT_MS = 2e3;
1832
+ async function bodyDigest(body) {
1833
+ if (!body || body.byteLength === 0) return "";
1834
+ const bytes = new Uint8Array(new ArrayBuffer(body.byteLength));
1835
+ bytes.set(body);
1836
+ const buf = await crypto.subtle.digest("SHA-256", bytes);
1837
+ return btoa(String.fromCharCode(...new Uint8Array(buf)));
1838
+ }
1839
+ function readCapable(req) {
1840
+ const v = req.headers.get(HEADER_CAPABLE);
1841
+ return v === "yes" || v === "no" ? v : "unknown";
1842
+ }
1843
+ function createAttestGate(deps) {
1844
+ const doFetch = deps.fetchImpl ?? fetch;
1845
+ const ttl = deps.policyTtlMs ?? DEFAULT_POLICY_TTL_MS;
1846
+ const policyTimeoutMs = deps.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
1847
+ let cachedMode = "off";
1848
+ let cachedAt = -Infinity;
1849
+ async function mode() {
1850
+ if (Date.now() - cachedAt < ttl) return cachedMode;
1851
+ try {
1852
+ const res = await doFetch(`${deps.moduleBaseUrl}/admin/attest/devices/attest-policy`, {
1853
+ headers: { apikey: deps.serviceRoleKey },
1854
+ signal: AbortSignal.timeout(policyTimeoutMs)
1855
+ });
1856
+ if (!res.ok) throw new Error(`attest-policy ${res.status}`);
1857
+ const body = await res.json();
1858
+ cachedMode = body.mode === "monitor" || body.mode === "enforce" ? body.mode : "off";
1859
+ cachedAt = Date.now();
1860
+ } catch {
1861
+ }
1862
+ return cachedMode;
1863
+ }
1864
+ return async function gate(req, body, _ctx) {
1865
+ const m = await mode();
1866
+ if (m === "off") {
1867
+ return { outcome: "pass", reason: "", device: null, observation: null, nextChallenge: null };
1868
+ }
1869
+ const deviceId = req.headers.get(HEADER_DEVICE_ID) ?? "";
1870
+ const payload = req.headers.get(HEADER_PAYLOAD) ?? "";
1871
+ const signature = req.headers.get(HEADER_SIGNATURE) ?? "";
1872
+ const hasEvidence = deviceId !== "" && payload !== "" && signature !== "";
1873
+ const observation = {
1874
+ capable: readCapable(req),
1875
+ attested: false,
1876
+ installationId: req.headers.get(HEADER_INSTALLATION) || null
1877
+ };
1878
+ if (m === "monitor") {
1879
+ observation.attested = hasEvidence;
1880
+ return { outcome: "pass", reason: "", device: null, observation, nextChallenge: null };
1881
+ }
1882
+ const url = new URL(req.url);
1883
+ let verdict;
1884
+ try {
1885
+ const res = await doFetch(`${deps.moduleBaseUrl}/admin/attest/devices/assert`, {
1886
+ method: "POST",
1887
+ headers: { apikey: deps.serviceRoleKey, "content-type": "application/json" },
1888
+ // A HUNG palauth must not become a hung tenant. Without this, a
1889
+ // process that accepts the connection and never answers holds every
1890
+ // request open until the client gives up — the gate would take the
1891
+ // whole stack down while claiming to protect it.
1892
+ signal: AbortSignal.timeout(policyTimeoutMs),
1893
+ body: JSON.stringify({
1894
+ installation_id: req.headers.get(HEADER_ANCHOR) ?? "",
1895
+ device_id: deviceId,
1896
+ challenge: req.headers.get(HEADER_CHALLENGE) ?? "",
1897
+ payload,
1898
+ signature,
1899
+ method: req.method,
1900
+ // pathname + search, NOT pathname alone.
1901
+ //
1902
+ // The client signs the path string it was given, and that string can
1903
+ // carry a query — the transport splits and reattaches one on purpose.
1904
+ // Recomputing from pathname alone would drop the query from the
1905
+ // signed definition, which breaks it BOTH ways: an attested call
1906
+ // carrying a query would fail to verify, and a captured assertion
1907
+ // could be replayed against the same route with different query
1908
+ // parameters, which is exactly the substitution the binding exists to
1909
+ // prevent.
1910
+ path: url.pathname + url.search,
1911
+ body_sha256: await bodyDigest(body)
1912
+ })
1913
+ });
1914
+ if (!res.ok) throw new Error(`assert ${res.status}`);
1915
+ verdict = await res.json();
1916
+ } catch {
1917
+ return {
1918
+ outcome: "reject",
1919
+ reason: "app_attest_required",
1920
+ device: null,
1921
+ observation,
1922
+ nextChallenge: null
1923
+ };
1924
+ }
1925
+ if (!verdict.ok) {
1926
+ const reason = verdict.reason === "assertion_invalid" || verdict.reason === "assertion_stale" ? verdict.reason : "app_attest_required";
1927
+ return {
1928
+ outcome: "reject",
1929
+ reason,
1930
+ device: null,
1931
+ observation,
1932
+ nextChallenge: verdict.next_challenge || null
1933
+ };
1934
+ }
1935
+ observation.attested = true;
1936
+ return {
1937
+ outcome: "pass",
1938
+ reason: "",
1939
+ device: {
1940
+ installationId: "",
1941
+ authDeviceId: deviceId,
1942
+ platform: "ios",
1943
+ assurance: "attested"
1944
+ },
1945
+ observation,
1946
+ nextChallenge: verdict.next_challenge || null
1947
+ };
1948
+ };
1949
+ }
1950
+ function createObservationSink(deps) {
1951
+ const doFetch = deps.fetchImpl ?? fetch;
1952
+ const maxBatch = deps.maxBatch ?? 1e3;
1953
+ const intervalMs = deps.intervalMs ?? 1e4;
1954
+ let buffer = [];
1955
+ let inFlight = Promise.resolve();
1956
+ const timer = setInterval(() => {
1957
+ void flush();
1958
+ }, intervalMs);
1959
+ timer.unref?.();
1960
+ function bucketsOf(batch) {
1961
+ const counts = { capable: 0, incapable: 0, unknown: 0, no_sdk: 0 };
1962
+ for (const o of batch) {
1963
+ if (o.attested || o.capable === "yes") counts.capable += 1;
1964
+ else if (o.capable === "no") counts.incapable += 1;
1965
+ else if (o.installationId) counts.unknown += 1;
1966
+ else counts.no_sdk += 1;
1967
+ }
1968
+ return counts;
1969
+ }
1970
+ async function flush() {
1971
+ if (buffer.length === 0) return;
1972
+ const batch = buffer;
1973
+ buffer = [];
1974
+ inFlight = (async () => {
1975
+ try {
1976
+ await doFetch(`${deps.moduleBaseUrl}/admin/attest/devices/observations`, {
1977
+ method: "POST",
1978
+ headers: { apikey: deps.serviceRoleKey, "content-type": "application/json" },
1979
+ body: JSON.stringify(bucketsOf(batch))
1980
+ });
1981
+ } catch {
1982
+ }
1983
+ })();
1984
+ await inFlight;
1985
+ }
1986
+ return {
1987
+ record(o) {
1988
+ buffer.push(o);
1989
+ if (buffer.length >= maxBatch) void flush();
1990
+ },
1991
+ flush,
1992
+ stop() {
1993
+ clearInterval(timer);
1994
+ }
1995
+ };
1996
+ }
1997
+
1822
1998
  // src/engine/upload.ts
1823
1999
  var AUTHORIZE_PATH = "/__palbase/upload/authorize";
1824
2000
  var SIGNATURE_HEADER = "x-palbase-upload-signature";
@@ -1972,6 +2148,7 @@ function installEgressFence(policy) {
1972
2148
  }
1973
2149
 
1974
2150
  // src/engine/index.ts
2151
+ var MAX_BODY_BYTES = 10 * 1024 * 1024;
1975
2152
  var JSON_HEADERS = { "content-type": "application/json" };
1976
2153
  function fieldErrors(err) {
1977
2154
  return err.issues.map((i) => ({ field: i.path.join("."), message: i.message }));
@@ -2032,6 +2209,14 @@ async function createApp(opts) {
2032
2209
  const sql = opts.sql ?? await defaultSqlDriver(config);
2033
2210
  await sql.unsafe("select 1");
2034
2211
  const auth = new AuthVerifier({ jwksUrl: config.authJwksUrl, issuer: config.authIssuer });
2212
+ const attestGate = createAttestGate({
2213
+ moduleBaseUrl: config.moduleBaseUrl,
2214
+ serviceRoleKey: config.serviceRoleKey
2215
+ });
2216
+ const observations = createObservationSink({
2217
+ moduleBaseUrl: config.moduleBaseUrl,
2218
+ serviceRoleKey: config.serviceRoleKey
2219
+ });
2035
2220
  const limiter = new RateLimiter();
2036
2221
  const cache = opts.cache ?? makeMemoryCache();
2037
2222
  const log = opts.logger ?? console;
@@ -2113,6 +2298,8 @@ async function createApp(opts) {
2113
2298
  const hit = matchRoute(routes, req.method, url.pathname);
2114
2299
  if (!hit) return envelope("not_found", "No route matches this method and path", 404, requestId);
2115
2300
  const { meta } = hit.entry;
2301
+ let attestedDevice = null;
2302
+ let attestChallengeOut = null;
2116
2303
  const spec = effectiveAuth(meta.options?.auth, hit.entry.controllerAuth);
2117
2304
  const claims = await auth.verify(req.headers.get("authorization"));
2118
2305
  if (spec.required && !claims) {
@@ -2125,6 +2312,67 @@ async function createApp(opts) {
2125
2312
  if (claims && spec.verifiedEmail && claims.email_verified !== true) {
2126
2313
  return envelope("email_not_verified", "A verified email address is required", 403, requestId);
2127
2314
  }
2315
+ let parsedBody;
2316
+ let bodyRead = false;
2317
+ let rawBody = null;
2318
+ if (req.method !== "GET" && req.method !== "HEAD") {
2319
+ const declared = Number(req.headers.get("content-length") ?? "");
2320
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
2321
+ return new Response(
2322
+ JSON.stringify({
2323
+ error: "payload_too_large",
2324
+ error_description: `Request body exceeds ${MAX_BODY_BYTES} bytes`,
2325
+ status: 413,
2326
+ request_id: requestId
2327
+ }),
2328
+ { status: 413, headers: JSON_HEADERS }
2329
+ );
2330
+ }
2331
+ const buf = await req.arrayBuffer().catch(() => null);
2332
+ if (buf && buf.byteLength > MAX_BODY_BYTES) {
2333
+ return new Response(
2334
+ JSON.stringify({
2335
+ error: "payload_too_large",
2336
+ error_description: `Request body exceeds ${MAX_BODY_BYTES} bytes`,
2337
+ status: 413,
2338
+ request_id: requestId
2339
+ }),
2340
+ { status: 413, headers: JSON_HEADERS }
2341
+ );
2342
+ }
2343
+ if (buf && buf.byteLength > 0) rawBody = new Uint8Array(buf);
2344
+ }
2345
+ const readParsedBody = () => {
2346
+ if (bodyRead) return parsedBody;
2347
+ bodyRead = true;
2348
+ if (!rawBody) {
2349
+ parsedBody = {};
2350
+ return parsedBody;
2351
+ }
2352
+ try {
2353
+ parsedBody = JSON.parse(new TextDecoder().decode(rawBody));
2354
+ } catch {
2355
+ parsedBody = {};
2356
+ }
2357
+ return parsedBody;
2358
+ };
2359
+ const attest = await attestGate(req, rawBody, { userId });
2360
+ if (attest.observation) observations.record(attest.observation);
2361
+ if (attest.outcome === "reject") {
2362
+ const headers = { ...JSON_HEADERS };
2363
+ if (attest.nextChallenge) headers["x-palbase-attest-challenge"] = attest.nextChallenge;
2364
+ return new Response(
2365
+ JSON.stringify({
2366
+ error: attest.reason,
2367
+ error_description: "This endpoint requires a verified app instance",
2368
+ status: 401,
2369
+ request_id: requestId
2370
+ }),
2371
+ { status: 401, headers }
2372
+ );
2373
+ }
2374
+ if (attest.device && claims) attestedDevice = attest.device;
2375
+ attestChallengeOut = attest.nextChallenge;
2128
2376
  const retryAfter = limiter.check(
2129
2377
  meta.options?.rateLimit,
2130
2378
  RateLimiter.key(hit.entry.id, userId, req.headers),
@@ -2144,8 +2392,6 @@ async function createApp(opts) {
2144
2392
  }
2145
2393
  let completionUploadId = null;
2146
2394
  const args = [];
2147
- let parsedBody;
2148
- let bodyRead = false;
2149
2395
  let sseEnqueue = null;
2150
2396
  let sseSettle = async () => {
2151
2397
  };
@@ -2156,10 +2402,7 @@ async function createApp(opts) {
2156
2402
  for (const p of meta.params ?? []) {
2157
2403
  switch (p.kind) {
2158
2404
  case "body": {
2159
- if (!bodyRead) {
2160
- parsedBody = await req.json().catch(() => ({}));
2161
- bodyRead = true;
2162
- }
2405
+ readParsedBody();
2163
2406
  const r = p.schema.safeParse(parsedBody);
2164
2407
  if (!r.success) {
2165
2408
  return envelope("bad_request", "Request body failed validation", 400, requestId, {
@@ -2204,7 +2447,12 @@ async function createApp(opts) {
2204
2447
  email: claims.email,
2205
2448
  role: claims.role,
2206
2449
  emailVerified: claims.email_verified === true,
2207
- metadata: claims.metadata ?? {}
2450
+ metadata: claims.metadata ?? {},
2451
+ // Server-owned and only ever filled from a verdict palauth
2452
+ // returned. Never from a raw header, never from JWT metadata —
2453
+ // the type has said so since it was written and this is the
2454
+ // first writer.
2455
+ device: attestedDevice
2208
2456
  } : null;
2209
2457
  break;
2210
2458
  case "uploadedObject": {
@@ -2216,10 +2464,7 @@ async function createApp(opts) {
2216
2464
  requestId
2217
2465
  );
2218
2466
  }
2219
- if (!bodyRead) {
2220
- parsedBody = await req.json().catch(() => ({}));
2221
- bodyRead = true;
2222
- }
2467
+ readParsedBody();
2223
2468
  const envelopeIn = parsedBody;
2224
2469
  if (!envelopeIn?.uploadedObject) {
2225
2470
  return envelope("bad_request", "the completion call carried no uploaded object", 400, requestId);
@@ -2402,7 +2647,10 @@ async function createApp(opts) {
2402
2647
  contentType: JSON_HEADERS["content-type"] ?? "application/json"
2403
2648
  });
2404
2649
  }
2405
- return new Response(payload, { status: 200, headers: JSON_HEADERS });
2650
+ return new Response(payload, {
2651
+ status: 200,
2652
+ headers: attestChallengeOut ? { ...JSON_HEADERS, "x-palbase-attest-challenge": attestChallengeOut } : JSON_HEADERS
2653
+ });
2406
2654
  } catch (err) {
2407
2655
  await db.rollback(err);
2408
2656
  if (isHttpError(err)) {
@@ -2465,4 +2713,4 @@ export {
2465
2713
  installEgressFence,
2466
2714
  createApp
2467
2715
  };
2468
- //# sourceMappingURL=chunk-WH2EV2LT.js.map
2716
+ //# sourceMappingURL=chunk-VDF2T4AS.js.map