@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.
- package/dist/bin/palbase-backend.cjs +279 -15
- package/dist/bin/palbase-backend.cjs.map +1 -1
- package/dist/bin/palbase-backend.js +3 -3
- package/dist/{chunk-P2Q27SGP.js → chunk-CJSKYY76.js} +182 -2
- package/dist/chunk-CJSKYY76.js.map +1 -0
- package/dist/{chunk-XJ2RSHEU.js → chunk-CRQKCRGF.js} +16 -2
- package/dist/chunk-CRQKCRGF.js.map +1 -0
- package/dist/{chunk-7JSEN7UR.js → chunk-G4R6BTLV.js} +3 -182
- package/dist/chunk-G4R6BTLV.js.map +1 -0
- package/dist/{chunk-WH2EV2LT.js → chunk-VDF2T4AS.js} +265 -17
- package/dist/chunk-VDF2T4AS.js.map +1 -0
- package/dist/db/index.cjs +1 -1
- package/dist/db/index.cjs.map +1 -1
- package/dist/db/index.js +2 -2
- package/dist/engine/index.cjs +279 -15
- package/dist/engine/index.cjs.map +1 -1
- package/dist/engine/index.js +3 -3
- package/dist/index.cjs +15 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +6 -6
- package/docs/README.md +4 -3
- package/docs/llms-full.txt +4 -3
- package/package.json +1 -1
- package/template/AGENTS.md +3 -1
- package/template/controllers/notes.controller.ts +2 -2
- package/template/services/note.service.ts +5 -3
- package/dist/chunk-7JSEN7UR.js.map +0 -1
- package/dist/chunk-P2Q27SGP.js.map +0 -1
- package/dist/chunk-WH2EV2LT.js.map +0 -1
- package/dist/chunk-XJ2RSHEU.js.map +0 -1
|
@@ -2,11 +2,14 @@ import {
|
|
|
2
2
|
__requestALS,
|
|
3
3
|
__runStartHooks,
|
|
4
4
|
__runWithRuntime
|
|
5
|
-
} from "./chunk-
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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-
|
|
2716
|
+
//# sourceMappingURL=chunk-VDF2T4AS.js.map
|