@payez/next-mvp 4.4.6 → 4.4.8
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/api-handlers/account/change-password.js +28 -6
- package/dist/auth/better-auth.d.ts +62 -15
- package/dist/auth/better-auth.js +180 -19
- package/dist/client/better-auth-client.d.ts +101 -956
- package/dist/config/env.d.ts +0 -2
- package/dist/config/env.js +0 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +8 -1
- package/dist/lib/idp-client-config.js +8 -1
- package/dist/lib/never-in-response.d.ts +30 -0
- package/dist/lib/never-in-response.js +71 -0
- package/dist/lib/nextauth-secret.d.ts +51 -0
- package/dist/lib/nextauth-secret.js +141 -0
- package/dist/lib/safe-error-response.d.ts +6 -6
- package/dist/lib/safe-error-response.js +6 -6
- package/dist/lib/two-factor-verify-failure.d.ts +36 -3
- package/dist/lib/two-factor-verify-failure.js +79 -2
- package/dist/middleware/create-middleware.d.ts +2 -0
- package/dist/middleware/create-middleware.js +36 -0
- package/dist/pages/login/page.js +5 -0
- package/dist/pages/verify-code/page.js +4 -2
- package/dist/routes/auth/settings.js +4 -1
- package/dist/server/auth.d.ts +36 -294
- package/dist/server/auth.js +22 -25
- package/dist/vibe/client.d.ts +132 -16
- package/dist/vibe/client.js +288 -82
- package/dist/vibe/document-unwrap.d.ts +50 -0
- package/dist/vibe/document-unwrap.js +78 -0
- package/dist/vibe/errors.d.ts +17 -0
- package/dist/vibe/errors.js +21 -1
- package/dist/vibe/generic.d.ts +2 -49
- package/dist/vibe/generic.js +17 -78
- package/dist/vibe/index.d.ts +1 -1
- package/dist/vibe/index.js +2 -1
- package/package.json +17 -2
- package/src/api-handlers/account/change-password.ts +31 -6
- package/src/auth/better-auth.ts +188 -21
- package/src/config/env.ts +0 -2
- package/src/index.ts +4 -0
- package/src/lib/idp-client-config.ts +8 -1
- package/src/lib/never-in-response.ts +64 -0
- package/src/lib/nextauth-secret.ts +168 -0
- package/src/lib/safe-error-response.ts +6 -6
- package/src/lib/two-factor-verify-failure.ts +86 -5
- package/src/middleware/create-middleware.ts +42 -0
- package/src/pages/login/page.tsx +4 -0
- package/src/pages/verify-code/page.tsx +4 -2
- package/src/routes/auth/settings.ts +5 -1
- package/src/server/auth.ts +23 -29
- package/src/vibe/client.ts +378 -91
- package/src/vibe/document-unwrap.ts +103 -0
- package/src/vibe/errors.ts +24 -0
- package/src/vibe/generic.ts +8 -100
- package/src/vibe/index.ts +1 -0
package/dist/vibe/client.js
CHANGED
|
@@ -71,9 +71,11 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
71
71
|
};
|
|
72
72
|
})();
|
|
73
73
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
74
|
-
exports.vibe = exports.VibeClient = exports.VibeTableDelegate = void 0;
|
|
74
|
+
exports.vibe = exports.VibeClient = exports.VibeTableDelegate = exports.MAX_FIND_MANY_PAGE_SIZE = exports.MAX_BATCH_CREATE_ROWS = void 0;
|
|
75
|
+
exports.buildVibeFilter = buildVibeFilter;
|
|
75
76
|
exports.createVibeClient = createVibeClient;
|
|
76
77
|
const errors_1 = require("./errors");
|
|
78
|
+
const document_unwrap_1 = require("./document-unwrap");
|
|
77
79
|
// -----------------------------------------------------------------------------
|
|
78
80
|
// RETRY HELPERS
|
|
79
81
|
// -----------------------------------------------------------------------------
|
|
@@ -83,6 +85,75 @@ const DEFAULT_RETRY_CONFIG = {
|
|
|
83
85
|
maxDelay: 10000,
|
|
84
86
|
retryOnRateLimit: true,
|
|
85
87
|
};
|
|
88
|
+
/**
|
|
89
|
+
* PAY-1767: the most records `createMany` will create in one call. It is per-record and NOT atomic, so a
|
|
90
|
+
* ceiling bounds the blast radius of a partial failure. 50 is the house's only batch ceiling (Vibe's own
|
|
91
|
+
* DecryptController BATCH_TOO_LARGE = 50).
|
|
92
|
+
*/
|
|
93
|
+
exports.MAX_BATCH_CREATE_ROWS = 50;
|
|
94
|
+
/**
|
|
95
|
+
* PAY-1767: the default page size findMany asks the query route for. Core clamps pageSize/limit to 100
|
|
96
|
+
* (DocumentsController.Query), so this is the most one call returns.
|
|
97
|
+
*/
|
|
98
|
+
exports.MAX_FIND_MANY_PAGE_SIZE = 100;
|
|
99
|
+
/** The operators Core's VibeQueryBuilder accepts, from VibeOperators.All. */
|
|
100
|
+
const VIBE_OPERATORS = new Set(['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'contains', 'startsWith', 'endsWith', 'in', 'notIn', 'isNull', 'isNotNull', 'ilike']);
|
|
101
|
+
/**
|
|
102
|
+
* PAY-1767: map a Prisma-style `where` to Core's VibeFilterNode OBJECT.
|
|
103
|
+
*
|
|
104
|
+
* { status: 'active' } -> { 'status': { eq: 'active' } }
|
|
105
|
+
* { score: { gt: 5 } } -> { 'score': { gt: 5 } }
|
|
106
|
+
* { status: 'a', tier: { in: [] } } -> { AND: [ {status:{eq:'a'}}, {tier:{in:[]}} ] }
|
|
107
|
+
*
|
|
108
|
+
* A field name is used as-is: a top-level document column (document_id, created_at...) or a JSONB path
|
|
109
|
+
* (Core treats an unknown name as data->>'name'; a nested path like 'data.x' is honoured explicitly).
|
|
110
|
+
* An unknown OPERATOR throws - dropping an operator would widen the result silently.
|
|
111
|
+
*/
|
|
112
|
+
function buildVibeFilter(where, table) {
|
|
113
|
+
const leaves = [];
|
|
114
|
+
for (const [field, value] of Object.entries(where)) {
|
|
115
|
+
if (value === undefined)
|
|
116
|
+
continue;
|
|
117
|
+
const conditions = {};
|
|
118
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
119
|
+
for (const [op, val] of Object.entries(value)) {
|
|
120
|
+
if (!VIBE_OPERATORS.has(op)) {
|
|
121
|
+
throw new errors_1.VibeValidationError(`where.${field} uses unsupported operator '${op}'. Core accepts: ${[...VIBE_OPERATORS].join(', ')}.`, { table, field, operator: op });
|
|
122
|
+
}
|
|
123
|
+
conditions[op] = val;
|
|
124
|
+
}
|
|
125
|
+
if (Object.keys(conditions).length === 0) {
|
|
126
|
+
throw new errors_1.VibeValidationError(`where.${field} has no operator`, { table, field });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
else if (Array.isArray(value)) {
|
|
130
|
+
// SHOULD (NightHawk 64610): a bare array is a set membership, NOT equality. `{ id: [1,2] }` as
|
|
131
|
+
// eq:[1,2] compares a scalar to an array and matches nothing; map it to Core's `in`.
|
|
132
|
+
conditions.in = value;
|
|
133
|
+
}
|
|
134
|
+
else if (value === null) {
|
|
135
|
+
// SHOULD (NightHawk 64610): `{ x: null }` as eq:null compares against JSON null; Core has a dedicated
|
|
136
|
+
// isNull operator, so map it rather than emit an equality that silently misses.
|
|
137
|
+
conditions.isNull = true;
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
// A bare value is equality.
|
|
141
|
+
conditions.eq = value;
|
|
142
|
+
}
|
|
143
|
+
leaves.push({ [field]: conditions });
|
|
144
|
+
}
|
|
145
|
+
if (leaves.length === 0)
|
|
146
|
+
return {};
|
|
147
|
+
if (leaves.length === 1)
|
|
148
|
+
return leaves[0];
|
|
149
|
+
// Multiple fields default to AND (Core's VibeFilterNode AND group).
|
|
150
|
+
return { AND: leaves };
|
|
151
|
+
}
|
|
152
|
+
/** Read the total from whichever pagination shape Core returned (DocumentListResponse.pagination). */
|
|
153
|
+
function readPaginationTotal(response) {
|
|
154
|
+
const r = response;
|
|
155
|
+
return r.pagination?.totalCount ?? r.pagination?.total ?? r.meta?.total;
|
|
156
|
+
}
|
|
86
157
|
/** Sleep for a given number of milliseconds */
|
|
87
158
|
function sleep(ms) {
|
|
88
159
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
@@ -110,69 +181,113 @@ class VibeTableDelegate {
|
|
|
110
181
|
this.client = client;
|
|
111
182
|
}
|
|
112
183
|
/**
|
|
113
|
-
*
|
|
184
|
+
* PAY-1767: the Vibe API route for this table's document collection.
|
|
185
|
+
* `/v1/collections/{collection}/tables/{table}` (Core DocumentsController.cs:15) - the same shape
|
|
186
|
+
* generic.ts and sessions.ts already use. The OLD `/api/v1/vibe_app/{table}` had no route behind it.
|
|
187
|
+
*/
|
|
188
|
+
tableEndpoint(id) {
|
|
189
|
+
return this.client.tableEndpoint(this.tableName, id);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Find multiple records, WITH filtering, sorting and paging honoured.
|
|
193
|
+
*
|
|
194
|
+
* PAY-1767 (NightHawk 64534 MUST 1, QAPert 64530, BAPert 64535). The GET list route
|
|
195
|
+
* (`GET /v1/collections/{c}/tables/{t}`, Core DocumentsController.List :44-48) binds ONLY `page` and
|
|
196
|
+
* `pageSize` - it reads no filter, sort or offset, so sending where/orderBy/take/skip there DROPPED them
|
|
197
|
+
* silently. Through the proxy that is worse than loud: a proxied MVP service account is ADMIN mode
|
|
198
|
+
* (HasValidContext), so the ownership filter is bypassed and an unfiltered page is 20 ARBITRARY rows of
|
|
199
|
+
* the tenant - caller code doing `rows[0]` gets the WRONG record with no error.
|
|
200
|
+
*
|
|
201
|
+
* So this now goes to `POST .../tables/{t}/query`, which binds QueryRequest (Filter / OrderBy / Page /
|
|
202
|
+
* PageSize / Limit). The FILTER is a VibeFilterNode OBJECT - `{ "<field>": { "<op>": value } }` - NOT the
|
|
203
|
+
* filter ARRAY that sessions.ts sends: QueryRequest.Filter is typed VibeFilterNode, which has no array
|
|
204
|
+
* form (measured at Core origin/master; CliInitProjectService.cs:197 builds the same object shape). An
|
|
205
|
+
* unmappable option THROWS rather than being dropped.
|
|
114
206
|
*/
|
|
115
207
|
async findMany(options) {
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
208
|
+
const body = this.buildQueryBody(options);
|
|
209
|
+
const endpoint = `${this.tableEndpoint()}/query`;
|
|
210
|
+
const response = await this.client.request(endpoint, 'POST', body);
|
|
211
|
+
// MUST 2: the rows come back as document WRAPPERS; unwrap to the row's own fields.
|
|
212
|
+
const rows = (0, document_unwrap_1.extractVibeDocuments)(response.data);
|
|
213
|
+
// item B (NightHawk 64552): the TRUE population is Core's pagination.totalCount, NOT a page size.
|
|
214
|
+
const total = readPaginationTotal(response) ?? rows.length;
|
|
215
|
+
const limit = body.limit ?? body.pageSize;
|
|
216
|
+
const offset = (body.page - 1) * limit;
|
|
217
|
+
return {
|
|
218
|
+
data: rows,
|
|
219
|
+
// item B: `hasMore` so a caller cannot mistake a page for the whole table.
|
|
220
|
+
// MUST 2 (NightHawk 64610): it must account for the OFFSET - on the last page `rows.length < total`
|
|
221
|
+
// was still true, so a caller paging until hasMore is false never stopped.
|
|
222
|
+
hasMore: offset + rows.length < total,
|
|
223
|
+
meta: { total, limit, offset },
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* PAY-1767: map the Prisma-style findMany options onto Core's QueryRequest body. THROWS on anything the
|
|
228
|
+
* query endpoint cannot express, so nothing is silently dropped.
|
|
229
|
+
*/
|
|
230
|
+
buildQueryBody(options) {
|
|
231
|
+
const body = { page: 1, pageSize: exports.MAX_FIND_MANY_PAGE_SIZE };
|
|
232
|
+
if (options?.where && Object.keys(options.where).length > 0) {
|
|
233
|
+
body.filter = buildVibeFilter(options.where, this.tableName);
|
|
234
|
+
}
|
|
235
|
+
if (options?.orderBy && Object.keys(options.orderBy).length > 0) {
|
|
236
|
+
const orderBy = {};
|
|
237
|
+
for (const [field, dir] of Object.entries(options.orderBy)) {
|
|
238
|
+
orderBy[field] = dir === 'desc' ? 'desc' : 'asc';
|
|
141
239
|
}
|
|
240
|
+
body.orderBy = orderBy;
|
|
142
241
|
}
|
|
143
|
-
// Pagination
|
|
144
242
|
if (options?.take !== undefined) {
|
|
145
|
-
|
|
243
|
+
if (!Number.isInteger(options.take) || options.take < 0) {
|
|
244
|
+
throw new errors_1.VibeValidationError(`take must be a non-negative integer (got ${options.take})`, { table: this.tableName });
|
|
245
|
+
}
|
|
246
|
+
// MUST 1 (NightHawk 64610): Core CLAMPS pageSize to 100 (DocumentsController.Query:
|
|
247
|
+
// `if (pageSize > 100) pageSize = 100`), so take > 100 would return 100 rows while meta.limit claimed
|
|
248
|
+
// the larger size - and a skip computed from a 250 size would land on Core's page 2 of size 100
|
|
249
|
+
// (rows 100-199), not rows 250-499. Refuse it rather than return the wrong rows silently.
|
|
250
|
+
if (options.take > exports.MAX_FIND_MANY_PAGE_SIZE) {
|
|
251
|
+
throw new errors_1.VibeValidationError(`take ${options.take} exceeds the maximum page size ${exports.MAX_FIND_MANY_PAGE_SIZE}; the query endpoint ` +
|
|
252
|
+
`CLAMPS pageSize to ${exports.MAX_FIND_MANY_PAGE_SIZE}, so a larger take returns a short page while ` +
|
|
253
|
+
`reporting the larger limit. Page with take ${exports.MAX_FIND_MANY_PAGE_SIZE} and a skip multiple.`, { table: this.tableName, take: options.take, max: exports.MAX_FIND_MANY_PAGE_SIZE });
|
|
254
|
+
}
|
|
255
|
+
body.limit = options.take;
|
|
256
|
+
body.pageSize = options.take === 0 ? 0 : options.take;
|
|
146
257
|
}
|
|
147
258
|
if (options?.skip !== undefined) {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
// Sorting
|
|
151
|
-
if (options?.orderBy) {
|
|
152
|
-
const sortParts = [];
|
|
153
|
-
for (const [key, dir] of Object.entries(options.orderBy)) {
|
|
154
|
-
sortParts.push(dir === 'desc' ? `-${key}` : key);
|
|
259
|
+
if (!Number.isInteger(options.skip) || options.skip < 0) {
|
|
260
|
+
throw new errors_1.VibeValidationError(`skip must be a non-negative integer (got ${options.skip})`, { table: this.tableName });
|
|
155
261
|
}
|
|
156
|
-
|
|
157
|
-
|
|
262
|
+
// The query endpoint PAGES (page + pageSize); it cannot express a bare row offset. A skip that is a
|
|
263
|
+
// whole number of pages can be expressed exactly; anything else would return rows BEFORE the offset
|
|
264
|
+
// and look correct. Refuse it.
|
|
265
|
+
const size = body.limit ?? body.pageSize;
|
|
266
|
+
if (body.limit === undefined && options.take === undefined && options.skip % size !== 0) {
|
|
267
|
+
throw new errors_1.VibeValidationError(`skip ${options.skip} is not a multiple of the default page size ${size}; pass a take so the page ` +
|
|
268
|
+
`size is explicit, or use a multiple (the query endpoint pages, it has no bare offset)`, { table: this.tableName, skip: options.skip });
|
|
158
269
|
}
|
|
270
|
+
if (size <= 0 || options.skip % size !== 0) {
|
|
271
|
+
throw new errors_1.VibeValidationError(`skip ${options.skip} cannot be expressed as a page of size ${size} (the query endpoint pages; ` +
|
|
272
|
+
`skip must be a multiple of take)`, { table: this.tableName, skip: options.skip, take: options.take });
|
|
273
|
+
}
|
|
274
|
+
body.page = options.skip / size + 1;
|
|
159
275
|
}
|
|
160
|
-
|
|
161
|
-
const url = `${this.client.getBaseUrl()}/api/v1/vibe_app/${this.tableName}${queryString ? `?${queryString}` : ''}`;
|
|
162
|
-
const response = await this.client.request(url, 'GET');
|
|
163
|
-
return {
|
|
164
|
-
data: response.data,
|
|
165
|
-
meta: response.meta || { total: response.data.length, limit: options?.take || 50, offset: options?.skip || 0 },
|
|
166
|
-
};
|
|
276
|
+
return body;
|
|
167
277
|
}
|
|
168
278
|
/**
|
|
169
279
|
* Find a single record by ID.
|
|
170
280
|
* Throws VibeNotFoundError if not found.
|
|
171
281
|
*/
|
|
172
282
|
async findUnique(options) {
|
|
173
|
-
const
|
|
174
|
-
const response = await this.client.request(
|
|
175
|
-
|
|
283
|
+
const endpoint = this.tableEndpoint(options.where.id);
|
|
284
|
+
const response = await this.client.request(endpoint, 'GET');
|
|
285
|
+
// PAY-1767: unwrap the document wrapper so the caller gets the row's own fields.
|
|
286
|
+
const row = (0, document_unwrap_1.unwrapVibeDocument)(response.data);
|
|
287
|
+
if (!row) {
|
|
288
|
+
throw new errors_1.VibeNotFoundError(`Document ${options.where.id} not found in ${this.tableName}`, 'NOT_FOUND', { table: this.tableName, id: options.where.id });
|
|
289
|
+
}
|
|
290
|
+
return row;
|
|
176
291
|
}
|
|
177
292
|
/**
|
|
178
293
|
* Find a single record by ID.
|
|
@@ -190,60 +305,106 @@ class VibeTableDelegate {
|
|
|
190
305
|
}
|
|
191
306
|
}
|
|
192
307
|
/**
|
|
193
|
-
* Find the first record
|
|
308
|
+
* Find the first record.
|
|
194
309
|
* Returns null if none found.
|
|
310
|
+
*
|
|
311
|
+
* PAY-1767: takes the first row of the (caller-owned, created_at-ordered) list. Any where/orderBy in
|
|
312
|
+
* `options` is passed through to findMany, which REFUSES them on this route rather than dropping them,
|
|
313
|
+
* so this cannot pretend to filter.
|
|
195
314
|
*/
|
|
196
315
|
async findFirst(options) {
|
|
197
|
-
const result = await this.findMany(
|
|
316
|
+
const result = await this.findMany(options);
|
|
198
317
|
return result.data[0] || null;
|
|
199
318
|
}
|
|
200
319
|
/**
|
|
201
320
|
* Create a new record.
|
|
202
321
|
*/
|
|
203
322
|
async create(options) {
|
|
204
|
-
const
|
|
205
|
-
const response = await this.client.request(
|
|
206
|
-
|
|
323
|
+
const endpoint = this.tableEndpoint();
|
|
324
|
+
const response = await this.client.request(endpoint, 'POST', options.data);
|
|
325
|
+
// SHOULD (NightHawk 64534): unwrap; a success with no data THROWS rather than returning {} typed as a row.
|
|
326
|
+
return this.requireRow(response.data, 'create');
|
|
207
327
|
}
|
|
208
328
|
/**
|
|
209
329
|
* Update an existing record by ID.
|
|
210
330
|
* Throws VibeNotFoundError if not found.
|
|
211
331
|
*/
|
|
212
332
|
async update(options) {
|
|
213
|
-
const
|
|
214
|
-
const response = await this.client.request(
|
|
215
|
-
return response.data;
|
|
333
|
+
const endpoint = this.tableEndpoint(options.where.id);
|
|
334
|
+
const response = await this.client.request(endpoint, 'PUT', options.data);
|
|
335
|
+
return this.requireRow(response.data, 'update');
|
|
216
336
|
}
|
|
217
337
|
/**
|
|
218
338
|
* Delete a record by ID (soft delete).
|
|
219
339
|
* Throws VibeNotFoundError if not found.
|
|
340
|
+
*
|
|
341
|
+
* PAY-1767 (NightHawk 64578 item 2): Core answers DELETE with **204 No Content** (DocumentsController
|
|
342
|
+
* Delete returns NoContent()), so a SUCCESSFUL delete carries NO document - and there is none to unwrap.
|
|
343
|
+
* This must therefore NOT go through requireRow (that is the create/update rule: "a success WITH no
|
|
344
|
+
* document throws"). A 2xx with no body is the success here; the request layer already resolves it to an
|
|
345
|
+
* empty success. Returns null when the server sent no document.
|
|
220
346
|
*/
|
|
221
347
|
async delete(options) {
|
|
222
|
-
const
|
|
223
|
-
const response = await this.client.request(
|
|
224
|
-
return response.data;
|
|
348
|
+
const endpoint = this.tableEndpoint(options.where.id);
|
|
349
|
+
const response = await this.client.request(endpoint, 'DELETE');
|
|
350
|
+
return response?.data ? (0, document_unwrap_1.unwrapVibeDocument)(response.data) : null;
|
|
351
|
+
}
|
|
352
|
+
/** Unwrap a single-document response, or THROW - never return an empty object typed as a row. */
|
|
353
|
+
requireRow(wire, op) {
|
|
354
|
+
const row = wire ? (0, document_unwrap_1.unwrapVibeDocument)(wire) : null;
|
|
355
|
+
if (!row) {
|
|
356
|
+
throw new errors_1.VibeNotFoundError(`${op} on ${this.tableName} returned no document data`, 'NOT_FOUND', { table: this.tableName, op });
|
|
357
|
+
}
|
|
358
|
+
return row;
|
|
225
359
|
}
|
|
226
360
|
/**
|
|
227
361
|
* Count records matching the filter.
|
|
362
|
+
*
|
|
363
|
+
* PAY-1767 (NightHawk 64534): goes to POST /query with limit:0, which is Core's COUNT-ONLY mode
|
|
364
|
+
* (DocumentsController.Query returns an empty Data array plus Pagination.TotalCount). So this is the
|
|
365
|
+
* true population of the filter, not the first page's size.
|
|
228
366
|
*/
|
|
229
367
|
async count(options) {
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
|
|
368
|
+
const body = this.buildQueryBody({ where: options?.where, take: 0 });
|
|
369
|
+
const endpoint = `${this.tableEndpoint()}/query`;
|
|
370
|
+
const response = await this.client.request(endpoint, 'POST', body);
|
|
371
|
+
return readPaginationTotal(response) ?? 0;
|
|
233
372
|
}
|
|
234
373
|
/**
|
|
235
|
-
* Create multiple records
|
|
374
|
+
* Create multiple records.
|
|
375
|
+
*
|
|
376
|
+
* PAY-1767: the old path `/api/v1/vibe_app/batch` has no route (there is no documents batch endpoint in
|
|
377
|
+
* Core's Vibe Public API), so this CANNOT be atomic here. Until a Core batch route exists, this creates
|
|
378
|
+
* each record against the REAL table endpoint in turn.
|
|
379
|
+
*
|
|
380
|
+
* BOUNDED ON PURPOSE (BAPert 64527). Non-atomic is tolerable only while the blast radius is small, so a
|
|
381
|
+
* request above MAX_BATCH_CREATE_ROWS is REFUSED before anything is sent. 50 matches the house's only
|
|
382
|
+
* batch ceiling (Vibe's DecryptController BATCH_TOO_LARGE).
|
|
383
|
+
*
|
|
384
|
+
* PARTIAL FAILURE IS REPORTED, NOT SWALLOWED (NightHawk 64534 condition (a)). A failure at item k throws
|
|
385
|
+
* a VibeBatchCreateError that CARRIES the count and the document ids already created - a cap cannot fix
|
|
386
|
+
* partial failure, only reporting it can. The created rows are unwrapped (MUST 2) so their own fields are
|
|
387
|
+
* present, not the wrapper's.
|
|
236
388
|
*/
|
|
237
389
|
async createMany(options) {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
390
|
+
if (options.data.length > exports.MAX_BATCH_CREATE_ROWS) {
|
|
391
|
+
throw new errors_1.VibeServiceError(`createMany accepts at most ${exports.MAX_BATCH_CREATE_ROWS} records per call (got ${options.data.length}); ` +
|
|
392
|
+
`it is NOT atomic, so a larger batch could leave a partial write`);
|
|
393
|
+
}
|
|
394
|
+
const endpoint = this.tableEndpoint();
|
|
395
|
+
const created = [];
|
|
396
|
+
for (let i = 0; i < options.data.length; i++) {
|
|
397
|
+
try {
|
|
398
|
+
const response = await this.client.request(endpoint, 'POST', options.data[i]);
|
|
399
|
+
const row = (0, document_unwrap_1.unwrapVibeDocument)(response.data);
|
|
400
|
+
if (row?._vibe_doc_id != null)
|
|
401
|
+
created.push(row._vibe_doc_id);
|
|
402
|
+
}
|
|
403
|
+
catch (err) {
|
|
404
|
+
throw new errors_1.VibeBatchCreateError(`createMany created ${created.length} of ${options.data.length} records before failing at index ${i}`, { createdCount: created.length, createdIds: created, failedIndex: i, total: options.data.length }, err);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return { count: created.length, ids: created };
|
|
247
408
|
}
|
|
248
409
|
}
|
|
249
410
|
exports.VibeTableDelegate = VibeTableDelegate;
|
|
@@ -273,6 +434,9 @@ class VibeClient {
|
|
|
273
434
|
clientId: config.clientId || process.env.VIBE_CLIENT_ID || '',
|
|
274
435
|
idpUrl: config.idpUrl || process.env.IDP_URL || '',
|
|
275
436
|
appSigningKey: config.appSigningKey || process.env.VIBE_HMAC_KEY || '',
|
|
437
|
+
// PAY-1767: which collection the table delegates address. Library default is correct ('vibe_app');
|
|
438
|
+
// pinning it per-call avoids the VIBE_COLLECTION-global-retarget trap (POST-INSTALL.md item 16).
|
|
439
|
+
collection: config.collection || process.env.VIBE_COLLECTION || 'vibe_app',
|
|
276
440
|
fetch: config.fetch || globalThis.fetch,
|
|
277
441
|
retry: { ...DEFAULT_RETRY_CONFIG, ...config.retry },
|
|
278
442
|
};
|
|
@@ -315,14 +479,24 @@ class VibeClient {
|
|
|
315
479
|
return crypto.createHmac('sha256', keyBuffer).update(message).digest('base64');
|
|
316
480
|
}
|
|
317
481
|
}
|
|
482
|
+
/**
|
|
483
|
+
* PAY-1767: the Vibe API endpoint PATH for a table in this client's collection.
|
|
484
|
+
* `/v1/collections/{collection}/tables/{table}[/{id}]` - what the IDP proxy validates, signs and forwards.
|
|
485
|
+
* Returned as a PATH, not a URL: proxy mode needs no VIBE_API_URL (the proxy owns the base).
|
|
486
|
+
*/
|
|
487
|
+
tableEndpoint(table, id) {
|
|
488
|
+
const base = `/v1/collections/${this.config.collection}/tables/${table}`;
|
|
489
|
+
return id === undefined ? base : `${base}/${id}`;
|
|
490
|
+
}
|
|
318
491
|
/**
|
|
319
492
|
* Make a request through the IDP Vibe proxy.
|
|
320
493
|
* The proxy handles credential injection securely.
|
|
494
|
+
*
|
|
495
|
+
* PAY-1767: `endpoint` is the RAW path (+query) to forward. It is what gets signed and sent, so this
|
|
496
|
+
* takes a path directly rather than parsing one out of a URL - proxy mode is complete without a Vibe
|
|
497
|
+
* base URL, which is exactly what an endpoint-path call needs.
|
|
321
498
|
*/
|
|
322
|
-
async requestViaProxy(
|
|
323
|
-
// Extract the endpoint path from the full URL
|
|
324
|
-
const urlObj = new URL(url);
|
|
325
|
-
const endpoint = urlObj.pathname + urlObj.search;
|
|
499
|
+
async requestViaProxy(endpoint, method, body, authToken) {
|
|
326
500
|
const timestamp = Math.floor(Date.now() / 1000);
|
|
327
501
|
const signature = await this.generateProxySignature(timestamp, method, endpoint);
|
|
328
502
|
const proxyUrl = `${this.config.idpUrl}/api/vibe/proxy`;
|
|
@@ -336,10 +510,12 @@ class VibeClient {
|
|
|
336
510
|
if (authToken) {
|
|
337
511
|
headers['Authorization'] = `Bearer ${authToken}`;
|
|
338
512
|
}
|
|
513
|
+
// PAY-1767: the proxy binds Endpoint / Method / Data (Core VibeProxyController :376-391), so a write
|
|
514
|
+
// goes out as `data` - sending `body` (as this used to) arrived as data:null.
|
|
339
515
|
const proxyBody = {
|
|
340
516
|
method,
|
|
341
517
|
endpoint,
|
|
342
|
-
...(body !== undefined && { body }),
|
|
518
|
+
...(body !== undefined && { data: body }),
|
|
343
519
|
};
|
|
344
520
|
const response = await this.config.fetch(proxyUrl, {
|
|
345
521
|
method: 'POST',
|
|
@@ -350,7 +526,25 @@ class VibeClient {
|
|
|
350
526
|
const errorBody = await this.parseErrorResponse(response);
|
|
351
527
|
throw errors_1.VibeError.fromResponse(errorBody, response.status);
|
|
352
528
|
}
|
|
353
|
-
|
|
529
|
+
// PAY-1767 (NightHawk 64578 item 2): a 204 No Content (or any 2xx with an EMPTY body) is a SUCCESS with
|
|
530
|
+
// no payload - e.g. a successful DELETE. Parsing it as JSON throws, which surfaced a successful delete as
|
|
531
|
+
// an error. Return an empty success here; this is distinct from the SHOULD rule (that one throws when a
|
|
532
|
+
// create/update answers success but carries NO DOCUMENT), which lives in the delegates.
|
|
533
|
+
if (response.status === 204) {
|
|
534
|
+
return { success: true };
|
|
535
|
+
}
|
|
536
|
+
const raw = await response.text();
|
|
537
|
+
if (raw.trim() === '') {
|
|
538
|
+
return { success: true };
|
|
539
|
+
}
|
|
540
|
+
let responseData;
|
|
541
|
+
try {
|
|
542
|
+
responseData = JSON.parse(raw);
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
// A non-empty 2xx body that is not JSON is not a shape we can trust - fail rather than pretend.
|
|
546
|
+
throw new errors_1.VibeServiceError(`Vibe API answered ${response.status} with a body that is not JSON`, { status: response.status, bodyLength: raw.length });
|
|
547
|
+
}
|
|
354
548
|
if (!responseData.success) {
|
|
355
549
|
throw errors_1.VibeError.fromResponse(responseData, response.status);
|
|
356
550
|
}
|
|
@@ -368,13 +562,17 @@ class VibeClient {
|
|
|
368
562
|
* Routing:
|
|
369
563
|
* - All requests route through the IDP proxy (requires VIBE_HMAC_KEY + IDP_URL)
|
|
370
564
|
*
|
|
565
|
+
* PAY-1767: `urlOrPath` is EITHER a full URL or a bare Vibe path ('/v1/collections/...'). Proxy mode
|
|
566
|
+
* signs and forwards the PATH either way, so a caller with only a path (the table delegates) needs no
|
|
567
|
+
* Vibe base URL - VIBE_API_URL is not required to make a proxied call.
|
|
568
|
+
*
|
|
371
569
|
* Retry behavior:
|
|
372
570
|
* - 5xx errors: Retry up to maxRetries times with exponential backoff
|
|
373
571
|
* - 429 errors: Retry once after Retry-After delay (if retryOnRateLimit enabled)
|
|
374
572
|
* - 401/403 errors: No retry (auth errors)
|
|
375
573
|
* - Network errors: Retry with backoff
|
|
376
574
|
*/
|
|
377
|
-
async request(
|
|
575
|
+
async request(urlOrPath, method, body, authToken) {
|
|
378
576
|
if (!this.config.clientId) {
|
|
379
577
|
throw new errors_1.VibeServiceError('VIBE_CLIENT_ID is not configured');
|
|
380
578
|
}
|
|
@@ -386,8 +584,13 @@ class VibeClient {
|
|
|
386
584
|
if (!this.config.appSigningKey) {
|
|
387
585
|
throw new errors_1.VibeServiceError('VIBE_HMAC_KEY is not configured (required for proxy mode)');
|
|
388
586
|
}
|
|
587
|
+
// PAY-1767: a bare path is used as-is; a full URL is reduced to its path+query. Both sign identically.
|
|
588
|
+
const endpoint = urlOrPath.startsWith('/') ? urlOrPath : (() => {
|
|
589
|
+
const u = new URL(urlOrPath);
|
|
590
|
+
return u.pathname + u.search;
|
|
591
|
+
})();
|
|
389
592
|
// Route through IDP proxy
|
|
390
|
-
return this.requestViaProxy(
|
|
593
|
+
return this.requestViaProxy(endpoint, method, body, authToken);
|
|
391
594
|
}
|
|
392
595
|
// MVP requires proxy mode - direct Vibe access is not supported
|
|
393
596
|
throw new errors_1.VibeServiceError('Vibe proxy not configured. Set VIBE_HMAC_KEY + IDP_URL for proxy mode.');
|
|
@@ -413,10 +616,13 @@ class VibeClient {
|
|
|
413
616
|
}
|
|
414
617
|
/**
|
|
415
618
|
* Fetch the schema for type generation.
|
|
619
|
+
*
|
|
620
|
+
* PAY-1767: the old path `/api/v1/vibe_app/schema` has no route. The real per-collection schema route is
|
|
621
|
+
* `/v1/mvp/schemas/{collection}` (Core MvpSchemaController.cs:65). Returns the collection schema DTO.
|
|
416
622
|
*/
|
|
417
623
|
async getSchema() {
|
|
418
|
-
const
|
|
419
|
-
const response = await this.request(
|
|
624
|
+
const endpoint = `/v1/mvp/schemas/${this.config.collection}`;
|
|
625
|
+
const response = await this.request(endpoint, 'GET');
|
|
420
626
|
return response.data;
|
|
421
627
|
}
|
|
422
628
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vibe document wrapper + unwrapping.
|
|
3
|
+
*
|
|
4
|
+
* PAY-1767: extracted from generic.ts into its own module so the typed client (client.ts) can unwrap
|
|
5
|
+
* reads WITHOUT importing generic.ts - generic.ts imports client.ts, so a shared helper there would be
|
|
6
|
+
* a cycle. This module imports nothing from the vibe client.
|
|
7
|
+
*
|
|
8
|
+
* Vibe stores a row's own fields as a JSON STRING under the wrapper's `data` field, alongside storage
|
|
9
|
+
* columns (document_id, owner, timestamps). A caller that renders `response.data` directly sees the
|
|
10
|
+
* wrapper, so `row.name` is undefined. unwrapVibeDocument flattens the row out and keeps the storage
|
|
11
|
+
* document_id as `_vibe_doc_id` for update/delete paths.
|
|
12
|
+
*/
|
|
13
|
+
/** Vibe document wrapper structure. Vibe stores actual table data as a JSON string in the 'data' field. */
|
|
14
|
+
export interface VibeDocumentWrapper {
|
|
15
|
+
document_id: number;
|
|
16
|
+
client_id?: number;
|
|
17
|
+
user_id?: number;
|
|
18
|
+
collection?: string;
|
|
19
|
+
table_name?: string;
|
|
20
|
+
data: string | Record<string, unknown>;
|
|
21
|
+
collection_schema_id?: number;
|
|
22
|
+
created_at?: string;
|
|
23
|
+
created_by?: number;
|
|
24
|
+
updated_at?: string | null;
|
|
25
|
+
updated_by?: number | null;
|
|
26
|
+
deleted_at?: string | null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Unwrap a Vibe document response.
|
|
30
|
+
*
|
|
31
|
+
* @param doc - Raw Vibe document (wrapper format)
|
|
32
|
+
* @returns Unwrapped document with schema fields only; storage document_id preserved as `_vibe_doc_id`
|
|
33
|
+
* for update/delete paths. See PayEz-Core/docs/vibe-primary-key-standard.md
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* const raw = { document_id: 123, data: '{"user_id":15,"name":"John"}' };
|
|
37
|
+
* unwrapVibeDocument(raw); // => { user_id: 15, name: 'John', _vibe_doc_id: 123 }
|
|
38
|
+
*/
|
|
39
|
+
export declare function unwrapVibeDocument<T extends object = Record<string, unknown>>(doc: VibeDocumentWrapper | Record<string, unknown> | null | undefined): (T & {
|
|
40
|
+
_vibe_doc_id?: number;
|
|
41
|
+
}) | null;
|
|
42
|
+
/**
|
|
43
|
+
* Extract and unwrap an array of documents from a Vibe response.
|
|
44
|
+
*
|
|
45
|
+
* @param responseData - Raw Vibe API response ({ data: [...] }, { items: [...] }, { documents: [...] } or a bare array)
|
|
46
|
+
* @returns Array of unwrapped documents
|
|
47
|
+
*/
|
|
48
|
+
export declare function extractVibeDocuments<T extends Record<string, unknown> = Record<string, unknown>>(responseData: unknown): Array<T & {
|
|
49
|
+
_vibe_doc_id?: number;
|
|
50
|
+
}>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Vibe document wrapper + unwrapping.
|
|
4
|
+
*
|
|
5
|
+
* PAY-1767: extracted from generic.ts into its own module so the typed client (client.ts) can unwrap
|
|
6
|
+
* reads WITHOUT importing generic.ts - generic.ts imports client.ts, so a shared helper there would be
|
|
7
|
+
* a cycle. This module imports nothing from the vibe client.
|
|
8
|
+
*
|
|
9
|
+
* Vibe stores a row's own fields as a JSON STRING under the wrapper's `data` field, alongside storage
|
|
10
|
+
* columns (document_id, owner, timestamps). A caller that renders `response.data` directly sees the
|
|
11
|
+
* wrapper, so `row.name` is undefined. unwrapVibeDocument flattens the row out and keeps the storage
|
|
12
|
+
* document_id as `_vibe_doc_id` for update/delete paths.
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.unwrapVibeDocument = unwrapVibeDocument;
|
|
16
|
+
exports.extractVibeDocuments = extractVibeDocuments;
|
|
17
|
+
/**
|
|
18
|
+
* Unwrap a Vibe document response.
|
|
19
|
+
*
|
|
20
|
+
* @param doc - Raw Vibe document (wrapper format)
|
|
21
|
+
* @returns Unwrapped document with schema fields only; storage document_id preserved as `_vibe_doc_id`
|
|
22
|
+
* for update/delete paths. See PayEz-Core/docs/vibe-primary-key-standard.md
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* const raw = { document_id: 123, data: '{"user_id":15,"name":"John"}' };
|
|
26
|
+
* unwrapVibeDocument(raw); // => { user_id: 15, name: 'John', _vibe_doc_id: 123 }
|
|
27
|
+
*/
|
|
28
|
+
function unwrapVibeDocument(doc) {
|
|
29
|
+
if (!doc)
|
|
30
|
+
return null;
|
|
31
|
+
// Handle case where doc is already unwrapped (has schema fields directly)
|
|
32
|
+
if (!('document_id' in doc) && !('data' in doc)) {
|
|
33
|
+
return doc;
|
|
34
|
+
}
|
|
35
|
+
const wrapper = doc;
|
|
36
|
+
let parsedData = {};
|
|
37
|
+
if (typeof wrapper.data === 'string') {
|
|
38
|
+
try {
|
|
39
|
+
parsedData = JSON.parse(wrapper.data);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
console.warn('[vibe-document] Failed to parse document data JSON:', wrapper.data);
|
|
43
|
+
parsedData = {};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else if (typeof wrapper.data === 'object' && wrapper.data !== null) {
|
|
47
|
+
parsedData = wrapper.data;
|
|
48
|
+
}
|
|
49
|
+
// Preserve document_id as _vibe_doc_id for Vibe API update/delete paths only
|
|
50
|
+
const documentId = wrapper.document_id ?? doc.document_id;
|
|
51
|
+
if (documentId != null) {
|
|
52
|
+
parsedData._vibe_doc_id = documentId;
|
|
53
|
+
}
|
|
54
|
+
return parsedData;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Extract and unwrap an array of documents from a Vibe response.
|
|
58
|
+
*
|
|
59
|
+
* @param responseData - Raw Vibe API response ({ data: [...] }, { items: [...] }, { documents: [...] } or a bare array)
|
|
60
|
+
* @returns Array of unwrapped documents
|
|
61
|
+
*/
|
|
62
|
+
function extractVibeDocuments(responseData) {
|
|
63
|
+
if (!responseData || typeof responseData !== 'object') {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
const data = responseData;
|
|
67
|
+
// Try common response shapes
|
|
68
|
+
const docs = data.data ??
|
|
69
|
+
data.items ??
|
|
70
|
+
data.documents ??
|
|
71
|
+
(Array.isArray(responseData) ? responseData : null);
|
|
72
|
+
if (!Array.isArray(docs)) {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
return docs
|
|
76
|
+
.map((doc) => unwrapVibeDocument(doc))
|
|
77
|
+
.filter((d) => d !== null);
|
|
78
|
+
}
|