@payez/next-mvp 4.8.2 → 4.8.3

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.
@@ -48,6 +48,7 @@ const startup_init_1 = require("../../lib/startup-init");
48
48
  const client_key_1 = require("../../lib/client-key");
49
49
  const roles_1 = require("../../lib/roles");
50
50
  const crypto_1 = require("crypto");
51
+ const filters_1 = require("../../vibe/filters");
51
52
  async function checkAdminRole(request) {
52
53
  const session = await (0, auth_1.getSession)(request);
53
54
  if (!session?.user) {
@@ -141,28 +142,19 @@ function createAuditHandler(config) {
141
142
  const queryBody = {
142
143
  page,
143
144
  pageSize,
144
- orderBy: 'created_at',
145
- orderDirection: 'desc',
145
+ // PAY-1810: orderBy is a { field: dir } map; a bare string is rejected or ignored by Core.
146
+ orderBy: (0, filters_1.orderByMap)('created_at', 'desc'),
146
147
  };
147
- const conditions = [];
148
- if (action) {
149
- conditions.push({ field: 'action', operator: 'eq', value: action });
150
- }
151
- if (userId) {
152
- conditions.push({ field: 'user_id', operator: 'eq', value: userId });
153
- }
154
- if (startDate) {
155
- conditions.push({ field: 'created_at', operator: 'gte', value: startDate });
156
- }
157
- if (endDate) {
158
- conditions.push({ field: 'created_at', operator: 'lte', value: endDate });
159
- }
160
- if (conditions.length === 1) {
161
- queryBody.filter = conditions[0];
162
- }
163
- else if (conditions.length > 1) {
164
- queryBody.filter = { operator: 'and', conditions };
165
- }
148
+ // PAY-1810: Core filter forms only (vibe/filters.ts). The old `{ operator, conditions }` had no `filters` key, so two or more filters
149
+ // returned zero rows.
150
+ const filter = (0, filters_1.allOf)([
151
+ action ? (0, filters_1.eq)('action', action) : null,
152
+ userId ? (0, filters_1.eq)('user_id', userId) : null,
153
+ startDate ? (0, filters_1.gte)('created_at', startDate) : null,
154
+ endDate ? (0, filters_1.lte)('created_at', endDate) : null,
155
+ ]);
156
+ if (filter)
157
+ queryBody.filter = filter;
166
158
  const result = await vibeServiceRequest('/v1/collections/vibe_app/tables/audit_logs/query', { method: 'POST', body: queryBody });
167
159
  if (!result.ok) {
168
160
  return server_1.NextResponse.json({ error: result.error, code: 'UPSTREAM_ERROR', correlationId: result.correlationId }, { status: result.status || 500 });
@@ -49,6 +49,7 @@ const startup_init_1 = require("../../lib/startup-init");
49
49
  const client_key_1 = require("../../lib/client-key");
50
50
  const roles_1 = require("../../lib/roles");
51
51
  const crypto_1 = require("crypto");
52
+ const filters_1 = require("../../vibe/filters");
52
53
  /**
53
54
  * Check if the current user has admin role
54
55
  */
@@ -156,23 +157,17 @@ function createSessionsHandler(config) {
156
157
  const queryBody = {
157
158
  page: 1,
158
159
  pageSize: 100,
159
- orderBy: 'created_at',
160
- orderDirection: 'desc',
160
+ // PAY-1810: orderBy is a { field: dir } map; a bare string is rejected or ignored by Core.
161
+ orderBy: (0, filters_1.orderByMap)('created_at', 'desc'),
161
162
  };
162
- // Build filter conditions
163
- const conditions = [];
164
- if (status && status !== 'all') {
165
- conditions.push({ field: 'status', operator: 'eq', value: status });
166
- }
167
- if (email) {
168
- conditions.push({ field: 'email', operator: 'like', value: `%${email}%` });
169
- }
170
- if (conditions.length === 1) {
171
- queryBody.filter = conditions[0];
172
- }
173
- else if (conditions.length > 1) {
174
- queryBody.filter = { operator: 'and', conditions };
175
- }
163
+ // PAY-1810: Core filter forms only (vibe/filters.ts): compound uses `filters`, and substring is `contains` (`like` is not a Core
164
+ // operator and a flat unknown operator is DROPPED, which returned every session).
165
+ const filter = (0, filters_1.allOf)([
166
+ status && status !== 'all' ? (0, filters_1.eq)('status', status) : null,
167
+ email ? (0, filters_1.contains)('email', email) : null,
168
+ ]);
169
+ if (filter)
170
+ queryBody.filter = filter;
176
171
  const result = await vibeServiceRequest('/v1/collections/vibe_app/tables/login_sessions/query', { method: 'POST', body: queryBody });
177
172
  if (!result.ok) {
178
173
  return server_1.NextResponse.json({ error: result.error, code: 'UPSTREAM_ERROR', correlationId: result.correlationId }, { status: result.status || 500 });
@@ -239,7 +234,9 @@ function createSessionsHandler(config) {
239
234
  if (action === 'revoke' && sessionId) {
240
235
  // Revoke single session
241
236
  const result = await vibeServiceRequest(`/v1/collections/vibe_app/tables/login_sessions/${sessionId}`, {
242
- method: 'PUT',
237
+ // PAY-1810 (NightHawk 75394): PATCH, not PUT. A PUT to a Vibe document REPLACES it (Core UpdateAsync SET data = body), so this 3-field body
238
+ // erased idp_user_id / session_token_hash / the rest of the row: the revoked row then vanished from the revocation check and the user list.
239
+ method: 'PATCH',
243
240
  body: {
244
241
  status: 'revoked',
245
242
  revoked_at: new Date().toISOString(),
@@ -252,39 +249,56 @@ function createSessionsHandler(config) {
252
249
  return server_1.NextResponse.json({ success: true });
253
250
  }
254
251
  if (action === 'revoke_all' && userId) {
255
- // Get all active sessions for user
256
- const queryResult = await vibeServiceRequest('/v1/collections/vibe_app/tables/login_sessions/query', {
257
- method: 'POST',
258
- body: {
259
- filter: {
260
- operator: 'and',
261
- conditions: [
262
- { field: 'idp_user_id', operator: 'eq', value: userId },
263
- { field: 'status', operator: 'eq', value: 'active' },
264
- ],
265
- },
266
- },
267
- });
268
- if (!queryResult.ok) {
269
- return server_1.NextResponse.json({ error: queryResult.error, code: 'UPSTREAM_ERROR', correlationId: queryResult.correlationId }, { status: queryResult.status || 500 });
270
- }
271
- const sessions = queryResult.data?.data || queryResult.data?.documents || [];
252
+ // PAY-1810: one /query call returns ONE page (Core's default page size is 20), so a user with more active sessions than a page would keep
253
+ // the rest signed in. Revoked sessions leave the `status = active` filter, so re-reading page 1 walks the whole set. It stops when a read
254
+ // is empty (complete), when a round revokes nothing (no progress: never loops on a failing write), or after REVOKE_ALL_MAX_ROUNDS.
255
+ const REVOKE_ALL_PAGE_SIZE = 50;
256
+ const REVOKE_ALL_MAX_ROUNDS = 20;
272
257
  let revokedCount = 0;
273
- // Revoke each session
274
- for (const session of sessions) {
275
- const id = session.id || session.document_id;
276
- const result = await vibeServiceRequest(`/v1/collections/vibe_app/tables/login_sessions/${id}`, {
277
- method: 'PUT',
258
+ let complete = false;
259
+ for (let round = 0; round < REVOKE_ALL_MAX_ROUNDS; round++) {
260
+ const queryResult = await vibeServiceRequest('/v1/collections/vibe_app/tables/login_sessions/query', {
261
+ method: 'POST',
278
262
  body: {
279
- status: 'revoked',
280
- revoked_at: new Date().toISOString(),
281
- revoked_by: `admin:${adminCheck.userId}`,
263
+ // PAY-1810: `conditions` is not a Core key (it read zero rows, so revoke_all revoked 0 and answered success); `filters` is.
264
+ filter: (0, filters_1.allOf)([(0, filters_1.eq)('idp_user_id', userId), (0, filters_1.eq)('status', 'active')]),
265
+ page: 1,
266
+ pageSize: REVOKE_ALL_PAGE_SIZE,
282
267
  },
283
268
  });
284
- if (result.ok)
285
- revokedCount++;
269
+ if (!queryResult.ok) {
270
+ // The first read failing is a plain upstream error (as before). A later one leaves a partial result, reported as incomplete.
271
+ if (round === 0) {
272
+ return server_1.NextResponse.json({ error: queryResult.error, code: 'UPSTREAM_ERROR', correlationId: queryResult.correlationId }, { status: queryResult.status || 500 });
273
+ }
274
+ break;
275
+ }
276
+ const sessions = queryResult.data?.data || queryResult.data?.documents || [];
277
+ if (sessions.length === 0) {
278
+ complete = true;
279
+ break;
280
+ }
281
+ let revokedThisRound = 0;
282
+ for (const session of sessions) {
283
+ const id = session.id || session.document_id;
284
+ const result = await vibeServiceRequest(`/v1/collections/vibe_app/tables/login_sessions/${id}`, {
285
+ method: 'PATCH', // PAY-1810: PATCH merges {status, revoked_at, revoked_by}; PUT would replace the whole row (see the single revoke above)
286
+ body: {
287
+ status: 'revoked',
288
+ revoked_at: new Date().toISOString(),
289
+ revoked_by: `admin:${adminCheck.userId}`,
290
+ },
291
+ });
292
+ if (result.ok) {
293
+ revokedCount++;
294
+ revokedThisRound++;
295
+ }
296
+ }
297
+ if (revokedThisRound === 0)
298
+ break;
286
299
  }
287
- return server_1.NextResponse.json({ success: true, revokedCount });
300
+ // success only when the whole set was revoked: a sign-out control must not say success when every write failed (Scout 75399).
301
+ return server_1.NextResponse.json({ success: complete, revokedCount, complete });
288
302
  }
289
303
  return server_1.NextResponse.json({ error: 'Invalid action' }, { status: 400 });
290
304
  },
@@ -142,7 +142,7 @@ function createStatsHandler(config) {
142
142
  // 1. Users count via HMAC proxy (Vibe collection query)
143
143
  vibeServiceRequest('/v1/collections/vibe_app/tables/users/query', {
144
144
  method: 'POST',
145
- body: { page: 1, pageSize: 500, orderBy: 'created_at', orderDirection: 'desc' },
145
+ body: { page: 1, pageSize: 500, orderBy: { created_at: 'desc' } }, // PAY-1810: orderBy is a { field: dir } map
146
146
  }),
147
147
  // 2. Tier distribution from analytics endpoint (uses purchases table)
148
148
  vibeServiceRequest('/v1/analytics/tier-distribution?includeTrend=false', { method: 'GET' }),
@@ -48,6 +48,7 @@ const startup_init_1 = require("../../lib/startup-init");
48
48
  const client_key_1 = require("../../lib/client-key");
49
49
  const roles_1 = require("../../lib/roles");
50
50
  const crypto_1 = require("crypto");
51
+ const filters_1 = require("../../vibe/filters");
51
52
  async function checkAdminRole(request) {
52
53
  const session = await (0, auth_1.getSession)(request);
53
54
  if (!session?.user) {
@@ -139,28 +140,17 @@ function createUsersHandler(config) {
139
140
  const queryBody = {
140
141
  page,
141
142
  pageSize,
142
- orderBy: 'created_at',
143
- orderDirection: 'desc',
143
+ // PAY-1810: orderBy is a { field: dir } map; a bare string is rejected or ignored by Core.
144
+ orderBy: (0, filters_1.orderByMap)('created_at', 'desc'),
144
145
  };
145
- const conditions = [];
146
- if (search) {
147
- conditions.push({
148
- operator: 'or',
149
- conditions: [
150
- { field: 'email', operator: 'like', value: `%${search}%` },
151
- { field: 'display_name', operator: 'like', value: `%${search}%` },
152
- ],
153
- });
154
- }
155
- if (tier) {
156
- conditions.push({ field: 'tier', operator: 'eq', value: tier });
157
- }
158
- if (conditions.length === 1) {
159
- queryBody.filter = conditions[0];
160
- }
161
- else if (conditions.length > 1) {
162
- queryBody.filter = { operator: 'and', conditions };
163
- }
146
+ // PAY-1810: Core filter forms only (vibe/filters.ts). The old `{ operator, conditions }` had no `filters` key, so a search returned zero rows,
147
+ // and `like` is not a Core operator; substring is `contains` (case-insensitive).
148
+ const filter = (0, filters_1.allOf)([
149
+ search ? (0, filters_1.anyOf)([(0, filters_1.contains)('email', search), (0, filters_1.contains)('display_name', search)]) : null,
150
+ tier ? (0, filters_1.eq)('tier', tier) : null,
151
+ ]);
152
+ if (filter)
153
+ queryBody.filter = filter;
164
154
  const result = await vibeServiceRequest('/v1/collections/vibe_app/tables/users/query', { method: 'POST', body: queryBody });
165
155
  if (!result.ok) {
166
156
  return server_1.NextResponse.json({ error: result.error, code: 'UPSTREAM_ERROR', correlationId: result.correlationId }, { status: result.status || 500 });
@@ -211,14 +201,16 @@ function createUsersHandler(config) {
211
201
  return server_1.NextResponse.json({ stats });
212
202
  }
213
203
  if (action === 'update_tier' && userId && tier) {
214
- const result = await vibeServiceRequest(`/v1/collections/vibe_app/tables/users/${userId}`, { method: 'PUT', body: { tier } });
204
+ const result = await vibeServiceRequest(`/v1/collections/vibe_app/tables/users/${userId}`, { method: 'PATCH', body: { tier } } // PAY-2041: merge one field; PUT replaced the whole user row
205
+ );
215
206
  if (!result.ok) {
216
207
  return server_1.NextResponse.json({ error: result.error, code: 'UPSTREAM_ERROR', correlationId: result.correlationId }, { status: result.status || 500 });
217
208
  }
218
209
  return server_1.NextResponse.json({ success: true });
219
210
  }
220
211
  if (action === 'update_credits' && userId && credits !== undefined) {
221
- const result = await vibeServiceRequest(`/v1/collections/vibe_app/tables/users/${userId}`, { method: 'PUT', body: { credits } });
212
+ const result = await vibeServiceRequest(`/v1/collections/vibe_app/tables/users/${userId}`, { method: 'PATCH', body: { credits } } // PAY-2041: merge one field; PUT replaced the whole user row
213
+ );
222
214
  if (!result.ok) {
223
215
  return server_1.NextResponse.json({ error: result.error, code: 'UPSTREAM_ERROR', correlationId: result.correlationId }, { status: result.status || 500 });
224
216
  }
@@ -48,6 +48,11 @@ export declare function createGetRecordHandler(config: AdminVibeHandlerConfig):
48
48
  /**
49
49
  * PUT /api/admin/vibe/data/[collection]/[table]/[id]
50
50
  * Update record (admin access)
51
+ *
52
+ * PAY-2041: this is deliberately a REPLACE. An editor save sends the whole record, and Core PUT sets the
53
+ * document data to the body, so a field the admin removed stays removed. Do not switch it to PATCH: a merge
54
+ * could never delete a key. Callers that hold only some fields (VibeTable.update, generic update, the
55
+ * users tier/credits writes) use PATCH instead. Ruled by BAPert 75440 (NightHawk 75437, Scout 75439 agree).
51
56
  */
52
57
  export declare function createUpdateRecordHandler(config: AdminVibeHandlerConfig): (request: NextRequest, { params }: {
53
58
  params: Promise<{
@@ -218,6 +218,11 @@ function createGetRecordHandler(config) {
218
218
  /**
219
219
  * PUT /api/admin/vibe/data/[collection]/[table]/[id]
220
220
  * Update record (admin access)
221
+ *
222
+ * PAY-2041: this is deliberately a REPLACE. An editor save sends the whole record, and Core PUT sets the
223
+ * document data to the body, so a field the admin removed stays removed. Do not switch it to PATCH: a merge
224
+ * could never delete a key. Callers that hold only some fields (VibeTable.update, generic update, the
225
+ * users tier/credits writes) use PATCH instead. Ruled by BAPert 75440 (NightHawk 75437, Scout 75439 agree).
221
226
  */
222
227
  function createUpdateRecordHandler(config) {
223
228
  return async function PUT(request, { params }) {
@@ -345,7 +345,7 @@ export declare class VibeClient {
345
345
  * - 401/403 errors: No retry (auth errors)
346
346
  * - Network errors: Retry with backoff
347
347
  */
348
- request<T>(urlOrPath: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE', body?: unknown, authToken?: string): Promise<VibeResponse<T>>;
348
+ request<T>(urlOrPath: string, method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', body?: unknown, authToken?: string): Promise<VibeResponse<T>>;
349
349
  /**
350
350
  * Parse error response body, handling non-JSON responses gracefully.
351
351
  */
@@ -331,7 +331,8 @@ class VibeTableDelegate {
331
331
  */
332
332
  async update(options) {
333
333
  const endpoint = this.tableEndpoint(options.where.id);
334
- const response = await this.client.request(endpoint, 'PUT', options.data);
334
+ // PAY-2041: PATCH, not PUT. Core PUT REPLACES the document (UpdateAsync SET data = body), and `data` here is Partial, so PUT erased every field not named. PATCH merges (DocumentsController.Patch).
335
+ const response = await this.client.request(endpoint, 'PATCH', options.data);
335
336
  return this.requireRow(response.data, 'update');
336
337
  }
337
338
  /**
@@ -0,0 +1,36 @@
1
+ /**
2
+ * PAY-1810: the ONE place the package builds Vibe /query filters and orderBy, in the shapes Core's VibeQueryBuilder accepts.
3
+ *
4
+ * Core accepts exactly three filter forms (PayEz.Vibe.Public.Api VibeQueryModels / VibeQueryBuilder):
5
+ * standard { field: { op: value } } with AND / OR arrays
6
+ * flat { field, operator, value }
7
+ * compound { operator: 'and' | 'or', filters: [ ...nodes ] } <- the key is `filters`, NOT `conditions`
8
+ * What goes wrong with the shapes this module replaces:
9
+ * - { operator, conditions } has no `filters` key, so Core reads `operator` / `conditions` as COLUMN names and returns ZERO rows
10
+ * (a broken search; for revoke-all, "revoked 0 sessions" reported as success).
11
+ * - operator 'like' is not a Core operator; in a flat filter it is DROPPED and the query returns ALL rows. Core's substring operator is
12
+ * `contains` (ILIKE, case-insensitive); `ilike` is case-insensitive EQUALS.
13
+ * - orderBy must be a { field: 'asc' | 'desc' } map (Core's model is Dictionary<string,string>); a bare string is rejected or ignored, never honoured.
14
+ */
15
+ export type VibeOperator = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains' | 'startsWith' | 'endsWith' | 'ilike' | 'in' | 'notIn';
16
+ export interface VibeFlatFilter {
17
+ field: string;
18
+ operator: VibeOperator;
19
+ value: unknown;
20
+ }
21
+ export interface VibeCompoundFilter {
22
+ operator: 'and' | 'or';
23
+ filters: VibeFilter[];
24
+ }
25
+ export type VibeFilter = VibeFlatFilter | VibeCompoundFilter;
26
+ export declare const eq: (field: string, value: unknown) => VibeFlatFilter;
27
+ export declare const gte: (field: string, value: unknown) => VibeFlatFilter;
28
+ export declare const lte: (field: string, value: unknown) => VibeFlatFilter;
29
+ /** Case-insensitive substring (Core `contains` = ILIKE). The value is the bare text: no % wildcards. */
30
+ export declare const contains: (field: string, value: string) => VibeFlatFilter;
31
+ /** AND of the given nodes: none -> undefined (no filter), one -> that node, several -> a compound with `filters`. */
32
+ export declare function allOf(nodes: Array<VibeFilter | undefined | null>): VibeFilter | undefined;
33
+ /** OR of the given nodes: none -> undefined, one -> that node, several -> a compound with `filters`. */
34
+ export declare function anyOf(nodes: Array<VibeFilter | undefined | null>): VibeFilter | undefined;
35
+ /** orderBy as Core binds it. */
36
+ export declare function orderByMap(field: string, direction?: 'asc' | 'desc'): Record<string, 'asc' | 'desc'>;
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ /**
3
+ * PAY-1810: the ONE place the package builds Vibe /query filters and orderBy, in the shapes Core's VibeQueryBuilder accepts.
4
+ *
5
+ * Core accepts exactly three filter forms (PayEz.Vibe.Public.Api VibeQueryModels / VibeQueryBuilder):
6
+ * standard { field: { op: value } } with AND / OR arrays
7
+ * flat { field, operator, value }
8
+ * compound { operator: 'and' | 'or', filters: [ ...nodes ] } <- the key is `filters`, NOT `conditions`
9
+ * What goes wrong with the shapes this module replaces:
10
+ * - { operator, conditions } has no `filters` key, so Core reads `operator` / `conditions` as COLUMN names and returns ZERO rows
11
+ * (a broken search; for revoke-all, "revoked 0 sessions" reported as success).
12
+ * - operator 'like' is not a Core operator; in a flat filter it is DROPPED and the query returns ALL rows. Core's substring operator is
13
+ * `contains` (ILIKE, case-insensitive); `ilike` is case-insensitive EQUALS.
14
+ * - orderBy must be a { field: 'asc' | 'desc' } map (Core's model is Dictionary<string,string>); a bare string is rejected or ignored, never honoured.
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.contains = exports.lte = exports.gte = exports.eq = void 0;
18
+ exports.allOf = allOf;
19
+ exports.anyOf = anyOf;
20
+ exports.orderByMap = orderByMap;
21
+ const eq = (field, value) => ({ field, operator: 'eq', value });
22
+ exports.eq = eq;
23
+ const gte = (field, value) => ({ field, operator: 'gte', value });
24
+ exports.gte = gte;
25
+ const lte = (field, value) => ({ field, operator: 'lte', value });
26
+ exports.lte = lte;
27
+ /** Case-insensitive substring (Core `contains` = ILIKE). The value is the bare text: no % wildcards. */
28
+ const contains = (field, value) => ({ field, operator: 'contains', value });
29
+ exports.contains = contains;
30
+ /** AND of the given nodes: none -> undefined (no filter), one -> that node, several -> a compound with `filters`. */
31
+ function allOf(nodes) {
32
+ const f = nodes.filter((n) => !!n);
33
+ if (f.length === 0)
34
+ return undefined;
35
+ return f.length === 1 ? f[0] : { operator: 'and', filters: f };
36
+ }
37
+ /** OR of the given nodes: none -> undefined, one -> that node, several -> a compound with `filters`. */
38
+ function anyOf(nodes) {
39
+ const f = nodes.filter((n) => !!n);
40
+ if (f.length === 0)
41
+ return undefined;
42
+ return f.length === 1 ? f[0] : { operator: 'or', filters: f };
43
+ }
44
+ /** orderBy as Core binds it. */
45
+ function orderByMap(field, direction = 'desc') {
46
+ return { [field]: direction };
47
+ }
@@ -208,7 +208,8 @@ class GenericTableDelegate {
208
208
  async update(options) {
209
209
  const path = vibeTablePath(this.collection, this.tableName, options.where.id);
210
210
  const url = `${this.client.getBaseUrl()}${path}`;
211
- const response = await this.client.request(url, 'PUT', options.data);
211
+ // PAY-2041: PATCH merges; PUT would replace the whole document with this Partial.
212
+ const response = await this.client.request(url, 'PATCH', options.data);
212
213
  const unwrapped = (0, document_unwrap_2.unwrapVibeDocument)(response.data);
213
214
  if (!unwrapped) {
214
215
  throw new errors_1.VibeServiceError('Failed to parse updated document');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payez/next-mvp",
3
- "version": "4.8.2",
3
+ "version": "4.8.3",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -13,6 +13,7 @@ import { getStartupIDPConfig } from '../../lib/startup-init';
13
13
  import { resolveClientKey } from '../../lib/client-key';
14
14
  import { ADMIN_ROLES, hasAnyRole } from '../../lib/roles';
15
15
  import { randomUUID } from 'crypto';
16
+ import { allOf, eq, gte, lte, orderByMap } from '../../vibe/filters';
16
17
 
17
18
  interface VibeRequestOptions {
18
19
  method: 'GET' | 'POST' | 'PUT' | 'DELETE';
@@ -131,29 +132,19 @@ export function createAuditHandler(config: AdminAuditHandlerConfig) {
131
132
  const queryBody: any = {
132
133
  page,
133
134
  pageSize,
134
- orderBy: 'created_at',
135
- orderDirection: 'desc',
135
+ // PAY-1810: orderBy is a { field: dir } map; a bare string is rejected or ignored by Core.
136
+ orderBy: orderByMap('created_at', 'desc'),
136
137
  };
137
138
 
138
- const conditions: any[] = [];
139
- if (action) {
140
- conditions.push({ field: 'action', operator: 'eq', value: action });
141
- }
142
- if (userId) {
143
- conditions.push({ field: 'user_id', operator: 'eq', value: userId });
144
- }
145
- if (startDate) {
146
- conditions.push({ field: 'created_at', operator: 'gte', value: startDate });
147
- }
148
- if (endDate) {
149
- conditions.push({ field: 'created_at', operator: 'lte', value: endDate });
150
- }
151
-
152
- if (conditions.length === 1) {
153
- queryBody.filter = conditions[0];
154
- } else if (conditions.length > 1) {
155
- queryBody.filter = { operator: 'and', conditions };
156
- }
139
+ // PAY-1810: Core filter forms only (vibe/filters.ts). The old `{ operator, conditions }` had no `filters` key, so two or more filters
140
+ // returned zero rows.
141
+ const filter = allOf([
142
+ action ? eq('action', action) : null,
143
+ userId ? eq('user_id', userId) : null,
144
+ startDate ? gte('created_at', startDate) : null,
145
+ endDate ? lte('created_at', endDate) : null,
146
+ ]);
147
+ if (filter) queryBody.filter = filter;
157
148
 
158
149
  const result = await vibeServiceRequest<any>(
159
150
  '/v1/collections/vibe_app/tables/audit_logs/query',
@@ -14,9 +14,10 @@ import { getStartupIDPConfig } from '../../lib/startup-init';
14
14
  import { resolveClientKey } from '../../lib/client-key';
15
15
  import { ADMIN_ROLES, hasAnyRole } from '../../lib/roles';
16
16
  import { randomUUID } from 'crypto';
17
+ import { allOf, contains, eq, orderByMap } from '../../vibe/filters';
17
18
 
18
19
  interface VibeRequestOptions {
19
- method: 'GET' | 'POST' | 'PUT' | 'DELETE';
20
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
20
21
  body?: unknown;
21
22
  }
22
23
 
@@ -154,24 +155,17 @@ export function createSessionsHandler(config: AdminSessionsHandlerConfig) {
154
155
  const queryBody: any = {
155
156
  page: 1,
156
157
  pageSize: 100,
157
- orderBy: 'created_at',
158
- orderDirection: 'desc',
158
+ // PAY-1810: orderBy is a { field: dir } map; a bare string is rejected or ignored by Core.
159
+ orderBy: orderByMap('created_at', 'desc'),
159
160
  };
160
161
 
161
- // Build filter conditions
162
- const conditions: any[] = [];
163
- if (status && status !== 'all') {
164
- conditions.push({ field: 'status', operator: 'eq', value: status });
165
- }
166
- if (email) {
167
- conditions.push({ field: 'email', operator: 'like', value: `%${email}%` });
168
- }
169
-
170
- if (conditions.length === 1) {
171
- queryBody.filter = conditions[0];
172
- } else if (conditions.length > 1) {
173
- queryBody.filter = { operator: 'and', conditions };
174
- }
162
+ // PAY-1810: Core filter forms only (vibe/filters.ts): compound uses `filters`, and substring is `contains` (`like` is not a Core
163
+ // operator and a flat unknown operator is DROPPED, which returned every session).
164
+ const filter = allOf([
165
+ status && status !== 'all' ? eq('status', status) : null,
166
+ email ? contains('email', email) : null,
167
+ ]);
168
+ if (filter) queryBody.filter = filter;
175
169
 
176
170
  const result = await vibeServiceRequest<any>(
177
171
  '/v1/collections/vibe_app/tables/login_sessions/query',
@@ -260,7 +254,9 @@ export function createSessionsHandler(config: AdminSessionsHandlerConfig) {
260
254
  const result = await vibeServiceRequest<any>(
261
255
  `/v1/collections/vibe_app/tables/login_sessions/${sessionId}`,
262
256
  {
263
- method: 'PUT',
257
+ // PAY-1810 (NightHawk 75394): PATCH, not PUT. A PUT to a Vibe document REPLACES it (Core UpdateAsync SET data = body), so this 3-field body
258
+ // erased idp_user_id / session_token_hash / the rest of the row: the revoked row then vanished from the revocation check and the user list.
259
+ method: 'PATCH',
264
260
  body: {
265
261
  status: 'revoked',
266
262
  revoked_at: new Date().toISOString(),
@@ -277,48 +273,66 @@ export function createSessionsHandler(config: AdminSessionsHandlerConfig) {
277
273
  }
278
274
 
279
275
  if (action === 'revoke_all' && userId) {
280
- // Get all active sessions for user
281
- const queryResult = await vibeServiceRequest<any>(
282
- '/v1/collections/vibe_app/tables/login_sessions/query',
283
- {
284
- method: 'POST',
285
- body: {
286
- filter: {
287
- operator: 'and',
288
- conditions: [
289
- { field: 'idp_user_id', operator: 'eq', value: userId },
290
- { field: 'status', operator: 'eq', value: 'active' },
291
- ],
292
- },
293
- },
294
- }
295
- );
296
-
297
- if (!queryResult.ok) {
298
- return NextResponse.json({ error: queryResult.error, code: 'UPSTREAM_ERROR', correlationId: queryResult.correlationId }, { status: queryResult.status || 500 });
299
- }
300
-
301
- const sessions = queryResult.data?.data || queryResult.data?.documents || [];
276
+ // PAY-1810: one /query call returns ONE page (Core's default page size is 20), so a user with more active sessions than a page would keep
277
+ // the rest signed in. Revoked sessions leave the `status = active` filter, so re-reading page 1 walks the whole set. It stops when a read
278
+ // is empty (complete), when a round revokes nothing (no progress: never loops on a failing write), or after REVOKE_ALL_MAX_ROUNDS.
279
+ const REVOKE_ALL_PAGE_SIZE = 50;
280
+ const REVOKE_ALL_MAX_ROUNDS = 20;
302
281
  let revokedCount = 0;
282
+ let complete = false;
303
283
 
304
- // Revoke each session
305
- for (const session of sessions) {
306
- const id = session.id || session.document_id;
307
- const result = await vibeServiceRequest<any>(
308
- `/v1/collections/vibe_app/tables/login_sessions/${id}`,
284
+ for (let round = 0; round < REVOKE_ALL_MAX_ROUNDS; round++) {
285
+ const queryResult = await vibeServiceRequest<any>(
286
+ '/v1/collections/vibe_app/tables/login_sessions/query',
309
287
  {
310
- method: 'PUT',
288
+ method: 'POST',
311
289
  body: {
312
- status: 'revoked',
313
- revoked_at: new Date().toISOString(),
314
- revoked_by: `admin:${adminCheck.userId}`,
290
+ // PAY-1810: `conditions` is not a Core key (it read zero rows, so revoke_all revoked 0 and answered success); `filters` is.
291
+ filter: allOf([eq('idp_user_id', userId), eq('status', 'active')]),
292
+ page: 1,
293
+ pageSize: REVOKE_ALL_PAGE_SIZE,
315
294
  },
316
295
  }
317
296
  );
318
- if (result.ok) revokedCount++;
297
+
298
+ if (!queryResult.ok) {
299
+ // The first read failing is a plain upstream error (as before). A later one leaves a partial result, reported as incomplete.
300
+ if (round === 0) {
301
+ return NextResponse.json({ error: queryResult.error, code: 'UPSTREAM_ERROR', correlationId: queryResult.correlationId }, { status: queryResult.status || 500 });
302
+ }
303
+ break;
304
+ }
305
+
306
+ const sessions = queryResult.data?.data || queryResult.data?.documents || [];
307
+ if (sessions.length === 0) {
308
+ complete = true;
309
+ break;
310
+ }
311
+
312
+ let revokedThisRound = 0;
313
+ for (const session of sessions) {
314
+ const id = session.id || session.document_id;
315
+ const result = await vibeServiceRequest<any>(
316
+ `/v1/collections/vibe_app/tables/login_sessions/${id}`,
317
+ {
318
+ method: 'PATCH', // PAY-1810: PATCH merges {status, revoked_at, revoked_by}; PUT would replace the whole row (see the single revoke above)
319
+ body: {
320
+ status: 'revoked',
321
+ revoked_at: new Date().toISOString(),
322
+ revoked_by: `admin:${adminCheck.userId}`,
323
+ },
324
+ }
325
+ );
326
+ if (result.ok) {
327
+ revokedCount++;
328
+ revokedThisRound++;
329
+ }
330
+ }
331
+ if (revokedThisRound === 0) break;
319
332
  }
320
333
 
321
- return NextResponse.json({ success: true, revokedCount });
334
+ // success only when the whole set was revoked: a sign-out control must not say success when every write failed (Scout 75399).
335
+ return NextResponse.json({ success: complete, revokedCount, complete });
322
336
  }
323
337
 
324
338
  return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
@@ -133,7 +133,7 @@ export function createStatsHandler(config: AdminStatsHandlerConfig) {
133
133
  // 1. Users count via HMAC proxy (Vibe collection query)
134
134
  vibeServiceRequest<any>('/v1/collections/vibe_app/tables/users/query', {
135
135
  method: 'POST',
136
- body: { page: 1, pageSize: 500, orderBy: 'created_at', orderDirection: 'desc' },
136
+ body: { page: 1, pageSize: 500, orderBy: { created_at: 'desc' } }, // PAY-1810: orderBy is a { field: dir } map
137
137
  }),
138
138
 
139
139
  // 2. Tier distribution from analytics endpoint (uses purchases table)
@@ -13,9 +13,10 @@ import { getStartupIDPConfig } from '../../lib/startup-init';
13
13
  import { resolveClientKey } from '../../lib/client-key';
14
14
  import { ADMIN_ROLES, hasAnyRole } from '../../lib/roles';
15
15
  import { randomUUID } from 'crypto';
16
+ import { allOf, anyOf, contains, eq, orderByMap } from '../../vibe/filters';
16
17
 
17
18
  interface VibeRequestOptions {
18
- method: 'GET' | 'POST' | 'PUT' | 'DELETE';
19
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
19
20
  body?: unknown;
20
21
  }
21
22
 
@@ -129,29 +130,17 @@ export function createUsersHandler(config: AdminUsersHandlerConfig) {
129
130
  const queryBody: any = {
130
131
  page,
131
132
  pageSize,
132
- orderBy: 'created_at',
133
- orderDirection: 'desc',
133
+ // PAY-1810: orderBy is a { field: dir } map; a bare string is rejected or ignored by Core.
134
+ orderBy: orderByMap('created_at', 'desc'),
134
135
  };
135
136
 
136
- const conditions: any[] = [];
137
- if (search) {
138
- conditions.push({
139
- operator: 'or',
140
- conditions: [
141
- { field: 'email', operator: 'like', value: `%${search}%` },
142
- { field: 'display_name', operator: 'like', value: `%${search}%` },
143
- ],
144
- });
145
- }
146
- if (tier) {
147
- conditions.push({ field: 'tier', operator: 'eq', value: tier });
148
- }
149
-
150
- if (conditions.length === 1) {
151
- queryBody.filter = conditions[0];
152
- } else if (conditions.length > 1) {
153
- queryBody.filter = { operator: 'and', conditions };
154
- }
137
+ // PAY-1810: Core filter forms only (vibe/filters.ts). The old `{ operator, conditions }` had no `filters` key, so a search returned zero rows,
138
+ // and `like` is not a Core operator; substring is `contains` (case-insensitive).
139
+ const filter = allOf([
140
+ search ? anyOf([contains('email', search), contains('display_name', search)]) : null,
141
+ tier ? eq('tier', tier) : null,
142
+ ]);
143
+ if (filter) queryBody.filter = filter;
155
144
 
156
145
  const result = await vibeServiceRequest<any>(
157
146
  '/v1/collections/vibe_app/tables/users/query',
@@ -222,7 +211,7 @@ export function createUsersHandler(config: AdminUsersHandlerConfig) {
222
211
  if (action === 'update_tier' && userId && tier) {
223
212
  const result = await vibeServiceRequest<any>(
224
213
  `/v1/collections/vibe_app/tables/users/${userId}`,
225
- { method: 'PUT', body: { tier } }
214
+ { method: 'PATCH', body: { tier } } // PAY-2041: merge one field; PUT replaced the whole user row
226
215
  );
227
216
 
228
217
  if (!result.ok) {
@@ -235,7 +224,7 @@ export function createUsersHandler(config: AdminUsersHandlerConfig) {
235
224
  if (action === 'update_credits' && userId && credits !== undefined) {
236
225
  const result = await vibeServiceRequest<any>(
237
226
  `/v1/collections/vibe_app/tables/users/${userId}`,
238
- { method: 'PUT', body: { credits } }
227
+ { method: 'PATCH', body: { credits } } // PAY-2041: merge one field; PUT replaced the whole user row
239
228
  );
240
229
 
241
230
  if (!result.ok) {
@@ -248,6 +248,11 @@ export function createGetRecordHandler(config: AdminVibeHandlerConfig) {
248
248
  /**
249
249
  * PUT /api/admin/vibe/data/[collection]/[table]/[id]
250
250
  * Update record (admin access)
251
+ *
252
+ * PAY-2041: this is deliberately a REPLACE. An editor save sends the whole record, and Core PUT sets the
253
+ * document data to the body, so a field the admin removed stays removed. Do not switch it to PATCH: a merge
254
+ * could never delete a key. Callers that hold only some fields (VibeTable.update, generic update, the
255
+ * users tier/credits writes) use PATCH instead. Ruled by BAPert 75440 (NightHawk 75437, Scout 75439 agree).
251
256
  */
252
257
  export function createUpdateRecordHandler(config: AdminVibeHandlerConfig) {
253
258
  return async function PUT(
@@ -491,7 +491,8 @@ export class VibeTableDelegate<T extends VibeTableName> {
491
491
  */
492
492
  async update(options: UpdateOptions<VibeTableType<T>>): Promise<VibeTableType<T>> {
493
493
  const endpoint = this.tableEndpoint(options.where.id);
494
- const response = await this.client.request<VibeDocumentWrapper>(endpoint, 'PUT', options.data);
494
+ // PAY-2041: PATCH, not PUT. Core PUT REPLACES the document (UpdateAsync SET data = body), and `data` here is Partial, so PUT erased every field not named. PATCH merges (DocumentsController.Patch).
495
+ const response = await this.client.request<VibeDocumentWrapper>(endpoint, 'PATCH', options.data);
495
496
  return this.requireRow(response.data, 'update');
496
497
  }
497
498
 
@@ -693,7 +694,7 @@ export class VibeClient {
693
694
  */
694
695
  private async requestViaProxy<T>(
695
696
  endpoint: string,
696
- method: 'GET' | 'POST' | 'PUT' | 'DELETE',
697
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
697
698
  body?: unknown,
698
699
  authToken?: string
699
700
  ): Promise<VibeResponse<T>> {
@@ -788,7 +789,7 @@ export class VibeClient {
788
789
  */
789
790
  async request<T>(
790
791
  urlOrPath: string,
791
- method: 'GET' | 'POST' | 'PUT' | 'DELETE',
792
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
792
793
  body?: unknown,
793
794
  authToken?: string
794
795
  ): Promise<VibeResponse<T>> {
@@ -0,0 +1,52 @@
1
+ /**
2
+ * PAY-1810: the ONE place the package builds Vibe /query filters and orderBy, in the shapes Core's VibeQueryBuilder accepts.
3
+ *
4
+ * Core accepts exactly three filter forms (PayEz.Vibe.Public.Api VibeQueryModels / VibeQueryBuilder):
5
+ * standard { field: { op: value } } with AND / OR arrays
6
+ * flat { field, operator, value }
7
+ * compound { operator: 'and' | 'or', filters: [ ...nodes ] } <- the key is `filters`, NOT `conditions`
8
+ * What goes wrong with the shapes this module replaces:
9
+ * - { operator, conditions } has no `filters` key, so Core reads `operator` / `conditions` as COLUMN names and returns ZERO rows
10
+ * (a broken search; for revoke-all, "revoked 0 sessions" reported as success).
11
+ * - operator 'like' is not a Core operator; in a flat filter it is DROPPED and the query returns ALL rows. Core's substring operator is
12
+ * `contains` (ILIKE, case-insensitive); `ilike` is case-insensitive EQUALS.
13
+ * - orderBy must be a { field: 'asc' | 'desc' } map (Core's model is Dictionary<string,string>); a bare string is rejected or ignored, never honoured.
14
+ */
15
+
16
+ export type VibeOperator = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains' | 'startsWith' | 'endsWith' | 'ilike' | 'in' | 'notIn';
17
+
18
+ export interface VibeFlatFilter {
19
+ field: string;
20
+ operator: VibeOperator;
21
+ value: unknown;
22
+ }
23
+ export interface VibeCompoundFilter {
24
+ operator: 'and' | 'or';
25
+ filters: VibeFilter[];
26
+ }
27
+ export type VibeFilter = VibeFlatFilter | VibeCompoundFilter;
28
+
29
+ export const eq = (field: string, value: unknown): VibeFlatFilter => ({ field, operator: 'eq', value });
30
+ export const gte = (field: string, value: unknown): VibeFlatFilter => ({ field, operator: 'gte', value });
31
+ export const lte = (field: string, value: unknown): VibeFlatFilter => ({ field, operator: 'lte', value });
32
+ /** Case-insensitive substring (Core `contains` = ILIKE). The value is the bare text: no % wildcards. */
33
+ export const contains = (field: string, value: string): VibeFlatFilter => ({ field, operator: 'contains', value });
34
+
35
+ /** AND of the given nodes: none -> undefined (no filter), one -> that node, several -> a compound with `filters`. */
36
+ export function allOf(nodes: Array<VibeFilter | undefined | null>): VibeFilter | undefined {
37
+ const f = nodes.filter((n): n is VibeFilter => !!n);
38
+ if (f.length === 0) return undefined;
39
+ return f.length === 1 ? f[0] : { operator: 'and', filters: f };
40
+ }
41
+
42
+ /** OR of the given nodes: none -> undefined, one -> that node, several -> a compound with `filters`. */
43
+ export function anyOf(nodes: Array<VibeFilter | undefined | null>): VibeFilter | undefined {
44
+ const f = nodes.filter((n): n is VibeFilter => !!n);
45
+ if (f.length === 0) return undefined;
46
+ return f.length === 1 ? f[0] : { operator: 'or', filters: f };
47
+ }
48
+
49
+ /** orderBy as Core binds it. */
50
+ export function orderByMap(field: string, direction: 'asc' | 'desc' = 'desc'): Record<string, 'asc' | 'desc'> {
51
+ return { [field]: direction };
52
+ }
@@ -224,7 +224,8 @@ export class GenericTableDelegate<T extends Record<string, unknown> = Record<str
224
224
  async update(options: { where: { id: number }; data: Partial<T> }): Promise<T & { _vibe_doc_id?: number }> {
225
225
  const path = vibeTablePath(this.collection, this.tableName, options.where.id);
226
226
  const url = `${this.client.getBaseUrl()}${path}`;
227
- const response = await this.client.request<VibeDocumentWrapper>(url, 'PUT', options.data);
227
+ // PAY-2041: PATCH merges; PUT would replace the whole document with this Partial.
228
+ const response = await this.client.request<VibeDocumentWrapper>(url, 'PATCH', options.data);
228
229
  const unwrapped = unwrapVibeDocument<T>(response.data);
229
230
  if (!unwrapped) {
230
231
  throw new VibeServiceError('Failed to parse updated document');