@openlup/platform 0.0.0-stage → 0.15.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/CHANGELOG.md +19 -0
- package/LICENSE +204 -0
- package/README.md +125 -2
- package/dist/bff/dispatch.d.ts +27 -0
- package/dist/bff/dispatch.js +47 -0
- package/dist/bff/envelope.d.ts +46 -0
- package/dist/bff/envelope.js +80 -0
- package/dist/bff/health.d.ts +24 -0
- package/dist/bff/health.js +19 -0
- package/dist/bff/index.d.ts +6 -0
- package/dist/bff/index.js +6 -0
- package/dist/bff/lazyRoute.d.ts +4 -0
- package/dist/bff/lazyRoute.js +13 -0
- package/dist/bff/requestContext.d.ts +17 -0
- package/dist/bff/requestContext.js +26 -0
- package/dist/bff/routerRewrite.d.ts +2 -0
- package/dist/bff/routerRewrite.js +31 -0
- package/dist/blob/binding.d.ts +16 -0
- package/dist/blob/binding.js +37 -0
- package/dist/blob/filesystem.d.ts +8 -0
- package/dist/blob/filesystem.js +71 -0
- package/dist/blob/index.d.ts +6 -0
- package/dist/blob/index.js +6 -0
- package/dist/blob/s3.d.ts +32 -0
- package/dist/blob/s3.js +56 -0
- package/dist/blob/s3Client.d.ts +25 -0
- package/dist/blob/s3Client.js +76 -0
- package/dist/blob/supabase.d.ts +40 -0
- package/dist/blob/supabase.js +59 -0
- package/dist/blob/vercel.d.ts +63 -0
- package/dist/blob/vercel.js +69 -0
- package/dist/composition/composeBundle.d.ts +47 -0
- package/dist/composition/composeBundle.js +45 -0
- package/dist/composition/httpBinding.d.ts +8 -0
- package/dist/composition/httpBinding.js +18 -0
- package/dist/composition/index.d.ts +3 -0
- package/dist/composition/index.js +3 -0
- package/dist/composition/nodeHost.d.ts +103 -0
- package/dist/composition/nodeHost.js +145 -0
- package/dist/data/binding.d.ts +34 -0
- package/dist/data/binding.js +92 -0
- package/dist/data/index.d.ts +5 -0
- package/dist/data/index.js +5 -0
- package/dist/data/postgresGateway.d.ts +42 -0
- package/dist/data/postgresGateway.js +135 -0
- package/dist/data/queryBuilder.d.ts +54 -0
- package/dist/data/queryBuilder.js +240 -0
- package/dist/data/queryBuilderSql.d.ts +76 -0
- package/dist/data/queryBuilderSql.js +145 -0
- package/dist/data/supabaseClientFactory.d.ts +34 -0
- package/dist/data/supabaseClientFactory.js +56 -0
- package/dist/data/supabaseGateway.d.ts +22 -0
- package/dist/data/supabaseGateway.js +49 -0
- package/dist/http/deadline.d.ts +20 -0
- package/dist/http/deadline.js +93 -0
- package/dist/http/index.d.ts +6 -0
- package/dist/http/index.js +5 -0
- package/dist/http/requestAdapter.d.ts +38 -0
- package/dist/http/requestAdapter.js +203 -0
- package/dist/http/runtime.d.ts +83 -0
- package/dist/http/runtime.js +199 -0
- package/dist/http/security.d.ts +44 -0
- package/dist/http/security.js +114 -0
- package/dist/http/staticAssets.d.ts +3 -0
- package/dist/http/staticAssets.js +42 -0
- package/dist/http/types.d.ts +23 -0
- package/dist/http/types.js +6 -0
- package/dist/job-ledger/index.d.ts +5 -0
- package/dist/job-ledger/index.js +3 -0
- package/dist/job-ledger/ports.d.ts +10 -0
- package/dist/job-ledger/ports.js +7 -0
- package/dist/job-ledger/postgres.d.ts +10 -0
- package/dist/job-ledger/postgres.js +101 -0
- package/dist/job-ledger/sql.d.ts +7 -0
- package/dist/job-ledger/sql.js +10 -0
- package/dist/job-ledger/supabase.d.ts +77 -0
- package/dist/job-ledger/supabase.js +202 -0
- package/dist/kernel/index.d.ts +1 -0
- package/dist/kernel/index.js +1 -0
- package/dist/kernel/platformKernel.d.ts +34 -0
- package/dist/kernel/platformKernel.js +115 -0
- package/dist/migrations/index.d.ts +1 -0
- package/dist/migrations/index.js +1 -0
- package/dist/migrations/postgresRunner.d.ts +50 -0
- package/dist/migrations/postgresRunner.js +176 -0
- package/dist/scheduler/advisoryLock.d.ts +44 -0
- package/dist/scheduler/advisoryLock.js +114 -0
- package/dist/scheduler/cronGuard.d.ts +10 -0
- package/dist/scheduler/cronGuard.js +19 -0
- package/dist/scheduler/cronSecret.d.ts +5 -0
- package/dist/scheduler/cronSecret.js +23 -0
- package/dist/scheduler/handlers.d.ts +9 -0
- package/dist/scheduler/handlers.js +10 -0
- package/dist/scheduler/httpEntry.d.ts +16 -0
- package/dist/scheduler/httpEntry.js +31 -0
- package/dist/scheduler/index.d.ts +9 -0
- package/dist/scheduler/index.js +9 -0
- package/dist/scheduler/jobRegistry.d.ts +21 -0
- package/dist/scheduler/jobRegistry.js +39 -0
- package/dist/scheduler/jobs.d.ts +48 -0
- package/dist/scheduler/jobs.js +92 -0
- package/dist/scheduler/nodeScheduler.d.ts +42 -0
- package/dist/scheduler/nodeScheduler.js +73 -0
- package/dist/scheduler/vercelScheduler.d.ts +29 -0
- package/dist/scheduler/vercelScheduler.js +51 -0
- package/package.json +108 -4
- package/release-gates.json +121 -0
- package/sql/20260809150000_job_run_ledger.sql +301 -0
- package/sql/20260809150001_job_run_ledger_v3.sql +70 -0
- package/src/bff/dispatch.ts +81 -0
- package/src/bff/envelope.ts +142 -0
- package/src/bff/health.ts +37 -0
- package/src/bff/index.ts +30 -0
- package/src/bff/lazyRoute.ts +28 -0
- package/src/bff/requestContext.ts +48 -0
- package/src/bff/routerRewrite.ts +38 -0
- package/src/blob/binding.ts +55 -0
- package/src/blob/filesystem.ts +86 -0
- package/src/blob/index.ts +17 -0
- package/src/blob/s3.ts +87 -0
- package/src/blob/s3Client.ts +91 -0
- package/src/blob/supabase.ts +88 -0
- package/src/blob/vercel.ts +127 -0
- package/src/composition/composeBundle.ts +109 -0
- package/src/composition/httpBinding.ts +30 -0
- package/src/composition/index.ts +17 -0
- package/src/composition/nodeHost.ts +289 -0
- package/src/data/binding.ts +157 -0
- package/src/data/index.ts +33 -0
- package/src/data/postgresGateway.ts +186 -0
- package/src/data/queryBuilder.ts +300 -0
- package/src/data/queryBuilderSql.ts +183 -0
- package/src/data/supabaseClientFactory.ts +92 -0
- package/src/data/supabaseGateway.ts +76 -0
- package/src/http/deadline.ts +117 -0
- package/src/http/index.ts +36 -0
- package/src/http/requestAdapter.ts +215 -0
- package/src/http/runtime.ts +319 -0
- package/src/http/security.ts +155 -0
- package/src/http/staticAssets.ts +42 -0
- package/src/http/types.ts +31 -0
- package/src/job-ledger/index.ts +25 -0
- package/src/job-ledger/ports.ts +27 -0
- package/src/job-ledger/postgres.ts +121 -0
- package/src/job-ledger/sql.ts +12 -0
- package/src/job-ledger/supabase.ts +294 -0
- package/src/kernel/index.ts +17 -0
- package/src/kernel/platformKernel.ts +153 -0
- package/src/migrations/index.ts +10 -0
- package/src/migrations/postgresRunner.ts +255 -0
- package/src/scheduler/advisoryLock.ts +145 -0
- package/src/scheduler/cronGuard.ts +30 -0
- package/src/scheduler/cronSecret.ts +26 -0
- package/src/scheduler/handlers.ts +24 -0
- package/src/scheduler/httpEntry.ts +49 -0
- package/src/scheduler/index.ts +47 -0
- package/src/scheduler/jobRegistry.ts +57 -0
- package/src/scheduler/jobs.ts +119 -0
- package/src/scheduler/nodeScheduler.ts +123 -0
- package/src/scheduler/vercelScheduler.ts +71 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// Vanilla-Postgres query-builder shim.
|
|
2
|
+
//
|
|
3
|
+
// Option A (approved): a SCOPED, injection-safe builder that emulates ONLY the supabase-js methods
|
|
4
|
+
// the 6 existing gateway consumers actually call — never a full PostgREST reimplementation. It
|
|
5
|
+
// translates a builder chain into a single PARAMETERIZED `pg` query ($1,$2,…). Values are never
|
|
6
|
+
// string-interpolated and identifiers are allowlist-validated; both invariants live in the pure SQL
|
|
7
|
+
// layer (queryBuilderSql.ts), which this file composes.
|
|
8
|
+
//
|
|
9
|
+
// Result semantics mirror PostgREST for the methods used:
|
|
10
|
+
// - default (await builder) -> { data: Row[], error }
|
|
11
|
+
// - .maybeSingle() -> { data: Row | null, error } (0/1 row; >1 is an error)
|
|
12
|
+
// - .single() -> { data: Row, error } (exactly 1 row; else error)
|
|
13
|
+
// - .range(from,to)/.limit(n) -> LIMIT/OFFSET, returns { data: Row[], error }
|
|
14
|
+
// - .select(c, {count:"exact"[,head]}) -> { data, count } from a separate aggregate; head omits rows
|
|
15
|
+
// - .insert(v).select(c).maybeSingle() -> INSERT ... RETURNING c, single row
|
|
16
|
+
// - .update(v)... -> UPDATE ... [RETURNING c]
|
|
17
|
+
// - error.code carries the pg SQLSTATE (e.g. 23505 unique-violation) so consumers' code-based
|
|
18
|
+
// branches (idempotency replay) keep working unchanged.
|
|
19
|
+
//
|
|
20
|
+
// The shim is constructed over a SINGLE acquired pg client (one per asActor/asService tx in the
|
|
21
|
+
// DataGatewayPort), so every query here runs inside that request's transaction with its RLS GUCs set.
|
|
22
|
+
|
|
23
|
+
import type { PoolClient } from "pg";
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
ParamAccumulator,
|
|
27
|
+
joinParts,
|
|
28
|
+
quoteIdent,
|
|
29
|
+
rangeNotSatisfiable,
|
|
30
|
+
renderColumns,
|
|
31
|
+
renderCountQuery,
|
|
32
|
+
renderFilter,
|
|
33
|
+
renderInsertStatement,
|
|
34
|
+
toShimError,
|
|
35
|
+
unwrapRpcRows,
|
|
36
|
+
type Filter,
|
|
37
|
+
type OrderBy,
|
|
38
|
+
type PgQueryExecutor,
|
|
39
|
+
type PgShimError,
|
|
40
|
+
type PgShimResult,
|
|
41
|
+
} from "./queryBuilderSql.js";
|
|
42
|
+
|
|
43
|
+
// Re-export the result/error/executor shapes so consumers import them from the builder entrypoint.
|
|
44
|
+
export type { PgQueryExecutor, PgShimError, PgShimResult };
|
|
45
|
+
|
|
46
|
+
interface BuilderState {
|
|
47
|
+
table: string;
|
|
48
|
+
mode: "select" | "insert" | "update";
|
|
49
|
+
columns: string; // RETURNING / SELECT projection
|
|
50
|
+
filters: Filter[];
|
|
51
|
+
orders: OrderBy[];
|
|
52
|
+
limit: number | null;
|
|
53
|
+
offset: number | null;
|
|
54
|
+
insertRows: Array<Record<string, unknown>> | null;
|
|
55
|
+
updateValues: Record<string, unknown> | null;
|
|
56
|
+
exactCount: boolean;
|
|
57
|
+
head: boolean;
|
|
58
|
+
rangeRequested: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Builder over one pg client. Chainable + awaitable (PromiseLike). Every value flows through a param
|
|
63
|
+
* accumulator, so the rendered SQL contains only $N placeholders for values.
|
|
64
|
+
*/
|
|
65
|
+
export class PgQueryBuilder<T = unknown> implements PromiseLike<PgShimResult<T>> {
|
|
66
|
+
private state: BuilderState;
|
|
67
|
+
|
|
68
|
+
constructor(
|
|
69
|
+
private readonly client: PgQueryExecutor,
|
|
70
|
+
table: string,
|
|
71
|
+
) {
|
|
72
|
+
this.state = {
|
|
73
|
+
table,
|
|
74
|
+
mode: "select",
|
|
75
|
+
columns: "*",
|
|
76
|
+
filters: [],
|
|
77
|
+
orders: [],
|
|
78
|
+
limit: null,
|
|
79
|
+
offset: null,
|
|
80
|
+
insertRows: null,
|
|
81
|
+
updateValues: null,
|
|
82
|
+
exactCount: false,
|
|
83
|
+
head: false,
|
|
84
|
+
rangeRequested: false,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
select(columns: string, options?: { count?: string; head?: boolean }): this {
|
|
89
|
+
this.state.columns = columns;
|
|
90
|
+
this.state.exactCount = options?.count === "exact";
|
|
91
|
+
this.state.head = options?.head === true;
|
|
92
|
+
return this;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
insert(values: Record<string, unknown> | Array<Record<string, unknown>>): this {
|
|
96
|
+
this.state.mode = "insert";
|
|
97
|
+
this.state.insertRows = Array.isArray(values) ? values : [values];
|
|
98
|
+
return this;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
update(values: Record<string, unknown>): this {
|
|
102
|
+
this.state.mode = "update";
|
|
103
|
+
this.state.updateValues = values;
|
|
104
|
+
return this;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
eq(column: string, value: unknown): this {
|
|
108
|
+
this.state.filters.push({ kind: "eq", column, value });
|
|
109
|
+
return this;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
gt(column: string, value: unknown): this {
|
|
113
|
+
this.state.filters.push({ kind: "gt", column, value });
|
|
114
|
+
return this;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
gte(column: string, value: unknown): this {
|
|
118
|
+
this.state.filters.push({ kind: "gte", column, value });
|
|
119
|
+
return this;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
lte(column: string, value: unknown): this {
|
|
123
|
+
this.state.filters.push({ kind: "lte", column, value });
|
|
124
|
+
return this;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
in(column: string, values: unknown[]): this {
|
|
128
|
+
this.state.filters.push({ kind: "in", column, values });
|
|
129
|
+
return this;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
is(column: string, value: unknown): this {
|
|
133
|
+
this.state.filters.push({ kind: "is", column, value });
|
|
134
|
+
return this;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
ilike(column: string, value: string): this {
|
|
138
|
+
this.state.filters.push({ kind: "ilike", column, value });
|
|
139
|
+
return this;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
order(column: string, options?: { ascending?: boolean }): this {
|
|
143
|
+
this.state.orders.push({ column, ascending: options?.ascending !== false });
|
|
144
|
+
return this;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
limit(count: number): this {
|
|
148
|
+
this.state.limit = count;
|
|
149
|
+
return this;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
range(from: number, to: number): Promise<PgShimResult<T>> {
|
|
153
|
+
// PostgREST range is inclusive on both ends → LIMIT (to-from+1) OFFSET from.
|
|
154
|
+
this.state.offset = from;
|
|
155
|
+
this.state.limit = to - from + 1;
|
|
156
|
+
this.state.rangeRequested = true;
|
|
157
|
+
return this.run("many");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async maybeSingle(): Promise<PgShimResult<T>> {
|
|
161
|
+
return this.run("maybe");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async single(): Promise<PgShimResult<T>> {
|
|
165
|
+
return this.run("single");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
then<R1 = PgShimResult<T>, R2 = never>(
|
|
169
|
+
onfulfilled?: ((value: PgShimResult<T>) => R1 | PromiseLike<R1>) | null,
|
|
170
|
+
onrejected?: ((reason: unknown) => R2 | PromiseLike<R2>) | null,
|
|
171
|
+
): Promise<R1 | R2> {
|
|
172
|
+
return this.run("many").then(onfulfilled, onrejected);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private async run(cardinality: "many" | "maybe" | "single"): Promise<PgShimResult<T>> {
|
|
176
|
+
try {
|
|
177
|
+
const count = this.state.exactCount ? await this.runExactCount() : null;
|
|
178
|
+
// A page past the end is a REFUSAL upstream, not an empty answer — but only once the total is
|
|
179
|
+
// known, which is why this sits behind the count and not behind `.range()` itself.
|
|
180
|
+
const unsatisfiable = this.state.rangeRequested ? rangeNotSatisfiable(this.state.offset, count) : null;
|
|
181
|
+
if (unsatisfiable) return { data: [] as T, error: unsatisfiable, count: null };
|
|
182
|
+
// head:true is a HEAD request: no body, so the client contract reports data:null and no row query.
|
|
183
|
+
if (this.state.head) return { data: null as T, error: null, count };
|
|
184
|
+
const { text, values } = this.compile();
|
|
185
|
+
const { rows } = await this.client.query(text, values);
|
|
186
|
+
// The client contract reports count: null unless a count was requested; `shape` must not invent one.
|
|
187
|
+
return { ...this.shape(rows, cardinality), count };
|
|
188
|
+
} catch (error) {
|
|
189
|
+
return { data: (cardinality === "many" ? [] : null) as T, error: toShimError(error), count: null };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private async runExactCount(): Promise<number> {
|
|
194
|
+
const params = new ParamAccumulator();
|
|
195
|
+
const { rows } = await this.client.query(renderCountQuery(this.state.table, this.state.filters, params), params.values);
|
|
196
|
+
return Number(rows[0]?.exact_count ?? 0);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private shape(rows: Record<string, unknown>[], cardinality: "many" | "maybe" | "single"): PgShimResult<T> {
|
|
200
|
+
if (cardinality === "many") {
|
|
201
|
+
return { data: rows as T, error: null };
|
|
202
|
+
}
|
|
203
|
+
if (rows.length > 1) {
|
|
204
|
+
// PostgREST returns 406 (PGRST116) when maybeSingle/single match multiple rows.
|
|
205
|
+
return { data: null as T, error: { code: "PGRST116", message: "multiple rows returned" } };
|
|
206
|
+
}
|
|
207
|
+
if (cardinality === "single" && rows.length === 0) {
|
|
208
|
+
return { data: null as T, error: { code: "PGRST116", message: "no rows returned" } };
|
|
209
|
+
}
|
|
210
|
+
return { data: (rows[0] ?? null) as T, error: null };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private compile(): { text: string; values: unknown[] } {
|
|
214
|
+
const params = new ParamAccumulator();
|
|
215
|
+
if (this.state.mode === "insert") return this.compileInsert(params);
|
|
216
|
+
if (this.state.mode === "update") return this.compileUpdate(params);
|
|
217
|
+
return this.compileSelect(params);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private compileSelect(params: ParamAccumulator): { text: string; values: unknown[] } {
|
|
221
|
+
const parts = [`SELECT ${renderColumns(this.state.columns)} FROM ${quoteIdent(this.state.table)}`];
|
|
222
|
+
parts.push(this.renderWhere(params));
|
|
223
|
+
parts.push(this.renderOrder());
|
|
224
|
+
parts.push(this.renderLimitOffset(params));
|
|
225
|
+
return { text: joinParts(parts), values: params.values };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private compileInsert(params: ParamAccumulator): { text: string; values: unknown[] } {
|
|
229
|
+
const text = renderInsertStatement(this.state.table, this.state.insertRows ?? [], this.state.columns, params);
|
|
230
|
+
return { text, values: params.values };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private compileUpdate(params: ParamAccumulator): { text: string; values: unknown[] } {
|
|
234
|
+
const values = this.state.updateValues ?? {};
|
|
235
|
+
const assignments = Object.keys(values)
|
|
236
|
+
.map((c) => `${quoteIdent(c)} = ${params.add(values[c])}`)
|
|
237
|
+
.join(", ");
|
|
238
|
+
if (assignments === "") throw new Error("pg-shim: update requires at least one column");
|
|
239
|
+
const parts = [`UPDATE ${quoteIdent(this.state.table)} SET ${assignments}`];
|
|
240
|
+
parts.push(this.renderWhere(params));
|
|
241
|
+
const returning = this.state.columns && this.state.columns !== "*"
|
|
242
|
+
? `RETURNING ${renderColumns(this.state.columns)}`
|
|
243
|
+
: "";
|
|
244
|
+
if (returning) parts.push(returning);
|
|
245
|
+
return { text: joinParts(parts), values: params.values };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private renderWhere(params: ParamAccumulator): string {
|
|
249
|
+
if (this.state.filters.length === 0) return "";
|
|
250
|
+
const clauses = this.state.filters.map((f) => renderFilter(f, params));
|
|
251
|
+
return `WHERE ${clauses.join(" AND ")}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private renderOrder(): string {
|
|
255
|
+
if (this.state.orders.length === 0) return "";
|
|
256
|
+
const cols = this.state.orders
|
|
257
|
+
.map((o) => `${quoteIdent(o.column)} ${o.ascending ? "ASC" : "DESC"}`)
|
|
258
|
+
.join(", ");
|
|
259
|
+
return `ORDER BY ${cols}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private renderLimitOffset(params: ParamAccumulator): string {
|
|
263
|
+
const parts: string[] = [];
|
|
264
|
+
if (this.state.limit !== null) parts.push(`LIMIT ${params.add(this.state.limit)}`);
|
|
265
|
+
if (this.state.offset !== null) parts.push(`OFFSET ${params.add(this.state.offset)}`);
|
|
266
|
+
return parts.join(" ");
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** supabase-js-style client over one acquired pg client: `.from(t)` + `.rpc(fn, args)`. */
|
|
271
|
+
export class PgGatewayClient {
|
|
272
|
+
constructor(private readonly client: PgQueryExecutor) {}
|
|
273
|
+
|
|
274
|
+
query(text: string, values?: unknown[]) { return this.client.query(text, values); }
|
|
275
|
+
|
|
276
|
+
from<T = Record<string, unknown>>(table: string): PgQueryBuilder<T> {
|
|
277
|
+
return new PgQueryBuilder<T>(this.client, table);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async rpc(functionName: string, args: Record<string, unknown> = {}): Promise<PgShimResult> {
|
|
281
|
+
try {
|
|
282
|
+
const name = quoteIdent(functionName);
|
|
283
|
+
const params = new ParamAccumulator();
|
|
284
|
+
// Named-arg invocation: every existing RPC uses `p_*` named params. `name => $n` binds by name
|
|
285
|
+
// so arg order is irrelevant and values are fully parameterized.
|
|
286
|
+
const namedArgs = Object.keys(args)
|
|
287
|
+
.map((key) => `${quoteIdent(key)} => ${params.add(args[key])}`)
|
|
288
|
+
.join(", ");
|
|
289
|
+
const text = `SELECT * FROM ${name}(${namedArgs})`;
|
|
290
|
+
const { rows } = await this.client.query(text, params.values);
|
|
291
|
+
// RPCs returning a scalar set-returning function expose the value under the function name;
|
|
292
|
+
// a single-column single-row result unwraps to that value (matches supabase-js rpc shape).
|
|
293
|
+
return { data: unwrapRpcRows(rows, functionName), error: null };
|
|
294
|
+
} catch (error) {
|
|
295
|
+
return { data: null, error: toShimError(error) };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export type { PoolClient };
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Vanilla-Postgres query-builder SQL primitives.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from queryBuilder.ts (300-LOC cap) — the pure, stateless SQL-fragment layer the builder
|
|
4
|
+
// composes: result/error shapes, the injection-safety chokepoint (ParamAccumulator), identifier
|
|
5
|
+
// quoting (allowlist), column projection, and filter rendering. VALUES ARE NEVER string-interpolated;
|
|
6
|
+
// they flow through ParamAccumulator as $N placeholders. Identifiers (table/column names) are validated
|
|
7
|
+
// against a strict allowlist regex and double-quoted, so they cannot smuggle SQL either.
|
|
8
|
+
|
|
9
|
+
/** PostgREST-shaped error the consumers narrow on (`.code`, `.message`, `.details`, `.hint`). */
|
|
10
|
+
export interface PgShimError {
|
|
11
|
+
code?: string;
|
|
12
|
+
message?: string;
|
|
13
|
+
details?: string;
|
|
14
|
+
hint?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PgShimResult<T = unknown> {
|
|
18
|
+
data: T;
|
|
19
|
+
error: PgShimError | null;
|
|
20
|
+
count?: number | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Minimal query surface we need from a pg client (Pool acquires a PoolClient per request). */
|
|
24
|
+
export interface PgQueryExecutor {
|
|
25
|
+
query(text: string, values?: unknown[]): Promise<{ rows: Record<string, unknown>[] }>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type Filter =
|
|
29
|
+
| { kind: "eq" | "gte" | "lte" | "gt"; column: string; value: unknown }
|
|
30
|
+
| { kind: "in"; column: string; values: unknown[] }
|
|
31
|
+
| { kind: "ilike"; column: string; value: string }
|
|
32
|
+
| { kind: "is"; column: string; value: unknown };
|
|
33
|
+
|
|
34
|
+
export interface OrderBy {
|
|
35
|
+
column: string;
|
|
36
|
+
ascending: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Identifiers (table + column names) are NEVER parameterizable in SQL, so they are validated against
|
|
40
|
+
// a strict allowlist and quoted. Consumers only ever pass static, code-literal identifiers; this
|
|
41
|
+
// guard turns any attempt to pass a crafted identifier into a thrown error rather than injected SQL.
|
|
42
|
+
const IDENTIFIER_RE = /^[a-z_][a-z0-9_]*$/i;
|
|
43
|
+
|
|
44
|
+
export function quoteIdent(raw: string): string {
|
|
45
|
+
if (!IDENTIFIER_RE.test(raw)) {
|
|
46
|
+
throw new Error(`pg-shim: unsafe identifier ${JSON.stringify(raw)}`);
|
|
47
|
+
}
|
|
48
|
+
return `"${raw}"`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// `select("a, b")` etc. — a comma list of identifiers or "*". Each segment is validated; "*" passes
|
|
52
|
+
// through. PostgREST embedding syntax (foo(bar)) is NOT used by any consumer, so it is rejected
|
|
53
|
+
// (fail-closed) rather than silently mistranslated.
|
|
54
|
+
export function renderColumns(columns: string): string {
|
|
55
|
+
const trimmed = columns.trim();
|
|
56
|
+
if (trimmed === "" || trimmed === "*") return "*";
|
|
57
|
+
return trimmed
|
|
58
|
+
.split(",")
|
|
59
|
+
.map((c) => c.trim())
|
|
60
|
+
.map((c) => (c === "*" ? "*" : quoteIdent(c)))
|
|
61
|
+
.join(", ");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function renderFilter(filter: Filter, params: ParamAccumulator): string {
|
|
65
|
+
const col = quoteIdent(filter.column);
|
|
66
|
+
switch (filter.kind) {
|
|
67
|
+
case "eq":
|
|
68
|
+
return `${col} = ${params.add(filter.value)}`;
|
|
69
|
+
case "gt":
|
|
70
|
+
return `${col} > ${params.add(filter.value)}`;
|
|
71
|
+
case "gte":
|
|
72
|
+
return `${col} >= ${params.add(filter.value)}`;
|
|
73
|
+
case "lte":
|
|
74
|
+
return `${col} <= ${params.add(filter.value)}`;
|
|
75
|
+
case "ilike":
|
|
76
|
+
return `${col} ILIKE ${params.add(filter.value)}`;
|
|
77
|
+
case "in": {
|
|
78
|
+
// Empty IN () is invalid SQL; PostgREST treats `.in([])` as matching nothing.
|
|
79
|
+
if (filter.values.length === 0) return "false";
|
|
80
|
+
const placeholders = filter.values.map((v) => params.add(v)).join(", ");
|
|
81
|
+
return `${col} IN (${placeholders})`;
|
|
82
|
+
}
|
|
83
|
+
case "is":
|
|
84
|
+
// .is(col, null) → IS NULL; .is(col, true/false) → IS TRUE/FALSE. Value is a literal keyword,
|
|
85
|
+
// not user data (consumers only pass null/true/false), so it is rendered, not parameterized.
|
|
86
|
+
if (filter.value === null) return `${col} IS NULL`;
|
|
87
|
+
if (filter.value === true) return `${col} IS TRUE`;
|
|
88
|
+
if (filter.value === false) return `${col} IS FALSE`;
|
|
89
|
+
throw new Error("pg-shim: .is() only supports null/true/false");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* PostgREST `count=exact` rendered as a SEPARATE aggregate over the same FROM/WHERE — deliberately
|
|
95
|
+
* NOT a `COUNT(*) OVER()` folded into the row query. PostgREST's exact count ignores limit/offset,
|
|
96
|
+
* and a window function emits no row at all for an empty page, so an offset past the end would
|
|
97
|
+
* report 0 instead of the true total. Keeping it separate also leaves the row projection
|
|
98
|
+
* byte-identical: there is no synthetic column to strip before cardinality shaping.
|
|
99
|
+
*/
|
|
100
|
+
export function renderCountQuery(table: string, filters: readonly Filter[], params: ParamAccumulator): string {
|
|
101
|
+
const where = filters.length === 0
|
|
102
|
+
? ""
|
|
103
|
+
: `WHERE ${filters.map((filter) => renderFilter(filter, params)).join(" AND ")}`;
|
|
104
|
+
return joinParts([`SELECT count(*) AS exact_count FROM ${quoteIdent(table)}`, where]);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* PostgREST refuses an out-of-bounds page instead of answering an empty one -- but ONLY when it
|
|
109
|
+
* knows the total, i.e. when an exact count was requested; without one it answers 200 and `[]`,
|
|
110
|
+
* which is what this shim already did. The boundary is strict: offset === total is a legal empty
|
|
111
|
+
* page, offset === total + 1 is not. Measured against PostgREST 14.10 before this was written,
|
|
112
|
+
* including the `details` sentence, which carries the two numbers a caller pages on.
|
|
113
|
+
*/
|
|
114
|
+
export function rangeNotSatisfiable(offset: number | null, total: number | null): PgShimError | null {
|
|
115
|
+
if (offset === null || total === null || offset <= total) return null;
|
|
116
|
+
return {
|
|
117
|
+
code: "PGRST103",
|
|
118
|
+
message: "Requested range not satisfiable",
|
|
119
|
+
details: `An offset of ${offset} was requested, but there are only ${total} rows.`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* INSERT ... VALUES ... RETURNING, with the column list taken from the FIRST row and every later
|
|
125
|
+
* row required to carry the same key set. Deriving the columns from row 0 alone and binding the
|
|
126
|
+
* rest positionally is how a batch used to lose a column silently: an extra key vanished, a missing
|
|
127
|
+
* one bound NULL over a DEFAULT. PostgREST answers PGRST102 "All object keys must match" to exactly
|
|
128
|
+
* this payload -- and keeps answering it under `Prefer: missing=default`, measured -- so the shim
|
|
129
|
+
* refuses too. Refusal, not union: parity is the point, and this builder exposes no Prefer header
|
|
130
|
+
* through which a caller could ask for anything else.
|
|
131
|
+
*/
|
|
132
|
+
export function renderInsertStatement(
|
|
133
|
+
table: string,
|
|
134
|
+
rows: readonly Record<string, unknown>[],
|
|
135
|
+
projection: string,
|
|
136
|
+
params: ParamAccumulator,
|
|
137
|
+
): string {
|
|
138
|
+
if (rows.length === 0) throw new Error("pg-shim: insert requires at least one row");
|
|
139
|
+
const columns = Object.keys(rows[0]!);
|
|
140
|
+
const signature = [...columns].sort().join("\u0000");
|
|
141
|
+
for (const row of rows) {
|
|
142
|
+
if ([...Object.keys(row)].sort().join("\u0000") !== signature) {
|
|
143
|
+
throw Object.assign(new Error("All object keys must match"), { code: "PGRST102" });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const tuples = rows.map((row) => `(${columns.map((column) => params.add(row[column])).join(", ")})`).join(", ");
|
|
147
|
+
return `INSERT INTO ${quoteIdent(table)} (${columns.map(quoteIdent).join(", ")}) VALUES ${tuples} `
|
|
148
|
+
+ `RETURNING ${renderColumns(projection)}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// supabase-js rpc() returns the function's return value directly: a scalar/json function yields that
|
|
152
|
+
// value; a TABLE/SETOF function yields the row array. Heuristic mirrors that: 1 row × 1 column whose
|
|
153
|
+
// name equals the function → the scalar; otherwise the rows array (TABLE-returning).
|
|
154
|
+
export function unwrapRpcRows(rows: Record<string, unknown>[], functionName: string): unknown {
|
|
155
|
+
if (rows.length === 1) {
|
|
156
|
+
const keys = Object.keys(rows[0]);
|
|
157
|
+
if (keys.length === 1 && keys[0] === functionName) return rows[0][functionName];
|
|
158
|
+
}
|
|
159
|
+
return rows;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Accumulates values and hands back $1,$2,… placeholders — the single injection-safety chokepoint. */
|
|
163
|
+
export class ParamAccumulator {
|
|
164
|
+
readonly values: unknown[] = [];
|
|
165
|
+
add(value: unknown): string {
|
|
166
|
+
this.values.push(value);
|
|
167
|
+
return `$${this.values.length}`;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function joinParts(parts: string[]): string {
|
|
172
|
+
return parts.filter((p) => p.trim() !== "").join(" ");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function toShimError(error: unknown): PgShimError {
|
|
176
|
+
const e = error as { code?: string; message?: string; detail?: string; hint?: string };
|
|
177
|
+
return {
|
|
178
|
+
code: e?.code,
|
|
179
|
+
message: e?.message ?? "query failed",
|
|
180
|
+
details: e?.detail,
|
|
181
|
+
hint: e?.hint,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Supabase client factory for the DataGatewayPort.
|
|
2
|
+
//
|
|
3
|
+
// ONE canonical definition of the two supabase-js client shapes BFF handlers need:
|
|
4
|
+
// - SERVICE-ROLE client (elevated, RLS-bypassing) — `createClient(url, serviceRoleKey, {auth:{...}})`
|
|
5
|
+
// - ACTOR client (RLS-bound via a Bearer access token) — anon key + `Authorization: Bearer <jwt>`
|
|
6
|
+
// Both use one exact option shape
|
|
7
|
+
// (`auth: { persistSession: false, autoRefreshToken: false }`, plus the actor's global Authorization
|
|
8
|
+
// header). The supabase-js `createClient`
|
|
9
|
+
// is always supplied by the application's composition root, so this package carries no SDK import
|
|
10
|
+
// and the adapter stays unit-testable without network.
|
|
11
|
+
|
|
12
|
+
/** Structural shape of the supabase-js `createClient` we depend on (injection seam for tests). */
|
|
13
|
+
export type SupabaseCreateClient = (
|
|
14
|
+
url: string,
|
|
15
|
+
key: string,
|
|
16
|
+
options?: {
|
|
17
|
+
auth?: { persistSession?: boolean; autoRefreshToken?: boolean };
|
|
18
|
+
global?: { headers?: Record<string, string> };
|
|
19
|
+
},
|
|
20
|
+
) => unknown;
|
|
21
|
+
|
|
22
|
+
export interface SupabaseDataGatewayEnv {
|
|
23
|
+
/** Project URL (SUPABASE_URL / VITE_SUPABASE_URL). */
|
|
24
|
+
url: string;
|
|
25
|
+
/** Anon/publishable key — used for actor (RLS-bound) clients. */
|
|
26
|
+
anonKey: string;
|
|
27
|
+
/** Service-role key — used for elevated (RLS-bypassing) clients. */
|
|
28
|
+
serviceRoleKey: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Read the gateway env using the same precedence the inline sites use today. */
|
|
32
|
+
export function readSupabaseDataGatewayEnv(
|
|
33
|
+
env: Record<string, string | undefined>,
|
|
34
|
+
): SupabaseDataGatewayEnv | null {
|
|
35
|
+
const url = env.SUPABASE_URL ?? env.VITE_SUPABASE_URL;
|
|
36
|
+
const anonKey =
|
|
37
|
+
env.VITE_SUPABASE_PUBLISHABLE_KEY ??
|
|
38
|
+
env.VITE_SUPABASE_ANON_KEY ??
|
|
39
|
+
env.SUPABASE_ANON_KEY;
|
|
40
|
+
const serviceRoleKey = env.SUPABASE_SERVICE_ROLE_KEY;
|
|
41
|
+
// anonKey is only needed for actor clients; service-role work needs only url + serviceRoleKey.
|
|
42
|
+
// We require url + serviceRoleKey (the elevated path the default bundle uses); anonKey may be
|
|
43
|
+
// empty for service-only deployments and is validated lazily when an actor client is requested.
|
|
44
|
+
return url && serviceRoleKey ? { url, anonKey: anonKey ?? "", serviceRoleKey } : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Read env for actor-only gateway consumers. Does not require the elevated service-role key. */
|
|
48
|
+
export function readSupabaseActorDataGatewayEnv(
|
|
49
|
+
env: Record<string, string | undefined>,
|
|
50
|
+
): SupabaseDataGatewayEnv | null {
|
|
51
|
+
const url = env.SUPABASE_URL ?? env.VITE_SUPABASE_URL;
|
|
52
|
+
const anonKey =
|
|
53
|
+
env.VITE_SUPABASE_PUBLISHABLE_KEY ??
|
|
54
|
+
env.VITE_SUPABASE_ANON_KEY ??
|
|
55
|
+
env.SUPABASE_ANON_KEY;
|
|
56
|
+
return url && anonKey ? { url, anonKey, serviceRoleKey: env.SUPABASE_SERVICE_ROLE_KEY ?? "" } : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build an elevated (service-role) supabase client — byte-identical to the inline
|
|
61
|
+
* `createClient(env.url, env.serviceRoleKey, { auth: { persistSession: false, autoRefreshToken: false } })`
|
|
62
|
+
* shape the BFF handlers use today.
|
|
63
|
+
*/
|
|
64
|
+
export function createServiceClient(
|
|
65
|
+
env: SupabaseDataGatewayEnv,
|
|
66
|
+
factory: SupabaseCreateClient,
|
|
67
|
+
): unknown {
|
|
68
|
+
return factory(env.url, env.serviceRoleKey, {
|
|
69
|
+
auth: { persistSession: false, autoRefreshToken: false },
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build an actor (RLS-bound) supabase client — anon key + the actor's Bearer access token, matching
|
|
75
|
+
* the inline `createAdminAuthClient` / `createCustomerClient` shape. When no token is supplied the
|
|
76
|
+
* global Authorization header is omitted (identical to the inline `accessToken ? {...} : {}`).
|
|
77
|
+
*/
|
|
78
|
+
export function createActorClient(
|
|
79
|
+
env: SupabaseDataGatewayEnv,
|
|
80
|
+
accessToken: string | null,
|
|
81
|
+
factory: SupabaseCreateClient,
|
|
82
|
+
): unknown {
|
|
83
|
+
if (!env.anonKey) {
|
|
84
|
+
throw new Error("supabase_data_gateway_actor_requires_anon_key");
|
|
85
|
+
}
|
|
86
|
+
return factory(env.url, env.anonKey, {
|
|
87
|
+
auth: { persistSession: false, autoRefreshToken: false },
|
|
88
|
+
global: {
|
|
89
|
+
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {},
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Supabase adapter for the platform-runtime DataGatewayPort.
|
|
2
|
+
//
|
|
3
|
+
// Centralizes the two persistence access modes the BFF handlers build inline today:
|
|
4
|
+
// - asService(work): elevated, RLS-bypassing — a service-role client (the default bundle's path).
|
|
5
|
+
// - asActor(claims, work): RLS-bound — an anon client carrying the actor's Bearer access token, so
|
|
6
|
+
// RLS evaluates the request as that principal (UUID-keep: claims.sub IS the supabase auth uuid).
|
|
7
|
+
// The work callback receives the concrete supabase client (typed `unknown` per the port contract;
|
|
8
|
+
// consumers cast to their narrow read/write surface). Behavior is byte-identical to the inline
|
|
9
|
+
// `createClient(...)` sites this replaces — same option shape, same Authorization header — so the
|
|
10
|
+
// default vercel-supabase bundle stays unchanged (golden-master byte-identical).
|
|
11
|
+
//
|
|
12
|
+
// POOLING SEMANTICS (documented, no behavior change):
|
|
13
|
+
// - Serverless (vercel-supabase): each invocation is short-lived; supabase-js talks to PostgREST
|
|
14
|
+
// over HTTP (PgBouncer in transaction-mode behind the scenes), so there is no long-lived pool to
|
|
15
|
+
// manage — a fresh client per asActor/asService call matches the current per-request creation and
|
|
16
|
+
// is correct for transaction-pooled connections (no session-level state assumed).
|
|
17
|
+
// - Long-running Node (node-supabase): the same supabase-js HTTP path applies; a direct-PG gateway
|
|
18
|
+
// (node-postgres) would instead hold a standard `pg.Pool` and run asActor work inside a
|
|
19
|
+
// `BEGIN; SET LOCAL request.jwt.claims = ...; ... COMMIT` tx-per-request for RLS. That is the
|
|
20
|
+
// MigrationRunner/postgres band's concern; this supabase adapter stays HTTP-pooled either way.
|
|
21
|
+
//
|
|
22
|
+
// asActor honors the access token supplied via `options.resolveAccessToken(claims)` (defaults to the
|
|
23
|
+
// claims' own bearer if present) so RLS is enforced; when no token resolves it falls back to an anon
|
|
24
|
+
// client (RLS still applies, just unauthenticated) rather than silently elevating — fail-safe.
|
|
25
|
+
|
|
26
|
+
import type { DataGatewayPort } from "@openlup/core/platform-runtime";
|
|
27
|
+
import {
|
|
28
|
+
createActorClient,
|
|
29
|
+
createServiceClient,
|
|
30
|
+
type SupabaseCreateClient,
|
|
31
|
+
type SupabaseDataGatewayEnv,
|
|
32
|
+
} from "./supabaseClientFactory.js";
|
|
33
|
+
|
|
34
|
+
type ActorClaims = { sub?: string; role?: string } | null;
|
|
35
|
+
|
|
36
|
+
export interface SupabaseDataGatewayOptions {
|
|
37
|
+
/** The supabase-js `createClient` (tests pass a stub). */
|
|
38
|
+
createClientImpl: SupabaseCreateClient;
|
|
39
|
+
/**
|
|
40
|
+
* Resolve an actor's Bearer access token from their claims. The port contract carries only
|
|
41
|
+
* `{ sub, role }`, but RLS needs the raw JWT; call sites that have the request token bind it here.
|
|
42
|
+
* Returns null → anon (RLS-bound but unauthenticated) client.
|
|
43
|
+
*/
|
|
44
|
+
resolveAccessToken?: (claims: ActorClaims) => string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build a supabase-backed DataGatewayPort from resolved env. Pure: no client is created until a
|
|
49
|
+
* port method runs work (matches the per-request creation the inline sites do today).
|
|
50
|
+
*/
|
|
51
|
+
export function createSupabaseDataGateway(
|
|
52
|
+
env: SupabaseDataGatewayEnv,
|
|
53
|
+
options: SupabaseDataGatewayOptions,
|
|
54
|
+
): DataGatewayPort {
|
|
55
|
+
const resolveAccessToken = options.resolveAccessToken ?? defaultResolveAccessToken;
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
async asService<T>(work: (gateway: unknown) => Promise<T>): Promise<T> {
|
|
59
|
+
const client = createServiceClient(env, options.createClientImpl);
|
|
60
|
+
return work(client);
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
async asActor<T>(claims: ActorClaims, work: (gateway: unknown) => Promise<T>): Promise<T> {
|
|
64
|
+
const accessToken = resolveAccessToken(claims);
|
|
65
|
+
const client = createActorClient(env, accessToken, options.createClientImpl);
|
|
66
|
+
return work(client);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Default token resolver: the port's claims shape doesn't carry a raw JWT, so without an injected
|
|
72
|
+
// resolver an actor request maps to an anon (unauthenticated, still RLS-bound) client. Call sites
|
|
73
|
+
// that hold the request bearer inject `resolveAccessToken` to make RLS see the real principal.
|
|
74
|
+
function defaultResolveAccessToken(_claims: ActorClaims): string | null {
|
|
75
|
+
return null;
|
|
76
|
+
}
|