@payez/next-mvp 4.8.2 → 4.8.4

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/README.md CHANGED
@@ -499,6 +499,33 @@ export function Providers({ children }: { children: React.ReactNode }) {
499
499
 
500
500
  ---
501
501
 
502
+ ## Register an agent (REG-002)
503
+
504
+ `@payez/next-mvp/agents` is a typed client over the tenant-admin agent door (`POST/GET api/client/agents`). The caller must be a tenant admin with completed MFA; the client (tenant) comes from the token, so there is no client id to pass.
505
+
506
+ ```ts
507
+ import { createAgentRegistryClient, AgentRegistryError } from '@payez/next-mvp/agents';
508
+
509
+ const agents = createAgentRegistryClient({
510
+ baseUrl: process.env.IDP_URL!, // absolute origin of the identity service
511
+ accessToken: () => getAdminAccessToken(), // a string, or a function returning one
512
+ });
513
+
514
+ try {
515
+ const { item } = await agents.register({ user_name: 'build-bot', owner_user_id: 42, host_class: 'rig_pane' });
516
+ // item.status.code === 'PENDING': the first mint is refused AGENT_PENDING until an admin activates it.
517
+ } catch (e) {
518
+ if (e instanceof AgentRegistryError) {
519
+ // e.code is Core's code as-is: VALIDATION_ERROR, OWNER_NOT_ELIGIBLE, OWNER_NOT_MEMBER, AGENT_NAME_TAKEN, ...
520
+ // e.status is the HTTP status (0 = no response). Nothing is retried; a 409 AGENT_NAME_TAKEN stays a 409.
521
+ }
522
+ throw e;
523
+ }
524
+
525
+ const { items } = await agents.list(); // every agent of your client
526
+ const { item: one } = await agents.get(41);
527
+ ```
528
+
502
529
  ## API Routes
503
530
 
504
531
  Access pre-built API handlers:
@@ -0,0 +1 @@
1
+ export * from './registry';
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./registry"), exports);
@@ -0,0 +1,73 @@
1
+ /** Core PayEzEndpoints.AgentTokens.AllowedHostClasses (PayEzEndpoints.cs:187,194,228). The server is the authority; this is for typing only. */
2
+ export declare const AGENT_HOST_CLASSES: readonly ["rig_pane", "contractor"];
3
+ export type AgentHostClass = (typeof AGENT_HOST_CLASSES)[number];
4
+ export interface RegisterAgentInput {
5
+ /** 3-64 chars, letters/digits/'.'/'_'/'-', starting with a letter or digit; unique across the IdP. */
6
+ user_name: string;
7
+ /** A person (user id) who is an approved, active member of this client. */
8
+ owner_user_id: number;
9
+ host_class: AgentHostClass;
10
+ }
11
+ export interface AgentRecord {
12
+ id: number;
13
+ user_name: string;
14
+ owner: {
15
+ kind: 'person';
16
+ user_id: number;
17
+ display_name: string | null;
18
+ approved: boolean;
19
+ } | null;
20
+ status: {
21
+ code: string;
22
+ reason: string | null;
23
+ };
24
+ host_class: string | null;
25
+ agent_profile_id: number | null;
26
+ }
27
+ export interface MintableApp {
28
+ id: number;
29
+ name: string;
30
+ allowed_scopes: unknown;
31
+ }
32
+ export interface RegisterAgentResult {
33
+ item: AgentRecord | null;
34
+ created: boolean;
35
+ audited: boolean;
36
+ }
37
+ export interface ListAgentsResult {
38
+ client_id: number;
39
+ demo_token_ready: boolean | null;
40
+ items: AgentRecord[];
41
+ apps: MintableApp[];
42
+ }
43
+ export interface GetAgentResult {
44
+ item: AgentRecord;
45
+ apps: MintableApp[];
46
+ }
47
+ /** Codes this door is known to answer. `string` stays in the union: an unknown code is surfaced, not hidden. */
48
+ export type AgentRegistryErrorCode = 'VALIDATION_ERROR' | 'OWNER_NOT_ELIGIBLE' | 'OWNER_NOT_MEMBER' | 'AGENT_NAME_TAKEN' | 'AUDIT_WRITE_FAILED_NOT_APPLIED' | 'MISSING_CLIENT_ID' | 'UNAUTHORIZED' | 'IDP_UNREACHABLE' | 'NETWORK_ERROR' | 'BAD_RESPONSE' | (string & {});
49
+ export declare class AgentRegistryError extends Error {
50
+ readonly code: AgentRegistryErrorCode;
51
+ /** HTTP status; 0 when no response arrived. */
52
+ readonly status: number;
53
+ readonly requestId?: string;
54
+ readonly details?: unknown;
55
+ constructor(code: AgentRegistryErrorCode, message: string, status: number, extra?: {
56
+ requestId?: string;
57
+ details?: unknown;
58
+ });
59
+ }
60
+ export interface AgentRegistryClientOptions {
61
+ /** Absolute origin of the identity service (e.g. IDP_URL). */
62
+ baseUrl: string;
63
+ /** A tenant-admin access token (completed MFA), or a function that returns one. */
64
+ accessToken: string | (() => string | Promise<string>);
65
+ /** Override for tests; defaults to bearerFetch (global fetch, never follows a redirect). */
66
+ fetch?: (input: string, init: RequestInit) => Promise<Response>;
67
+ }
68
+ export interface AgentRegistryClient {
69
+ register(input: RegisterAgentInput): Promise<RegisterAgentResult>;
70
+ list(): Promise<ListAgentsResult>;
71
+ get(id: number): Promise<GetAgentResult>;
72
+ }
73
+ export declare function createAgentRegistryClient(options: AgentRegistryClientOptions): AgentRegistryClient;
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AgentRegistryError = exports.AGENT_HOST_CLASSES = void 0;
4
+ exports.createAgentRegistryClient = createAgentRegistryClient;
5
+ /**
6
+ * REG-002 (Keel Registry): the SDK half of "Registration API and SDK".
7
+ *
8
+ * A typed client over the tenant-admin agent door in PayEz-Core:
9
+ * PayEz.Apis/PayEz.External.Id.Api/Controllers/ClientAgentsController.cs @ 22d44a34c
10
+ * POST api/client/agents Register (:332-367) body { user_name, owner_user_id, host_class } -> 201 { item, created, audited }
11
+ * GET api/client/agents List (:92-107) -> { client_id, demo_token_ready, items, apps }
12
+ * GET api/client/agents/{id} Get (:126-133) -> { item, apps } | 404
13
+ * Gate: payez_admin / idp_admin_ui_graft_admin with completed MFA (:39-40). The client comes from the TOKEN, never from the
14
+ * body, so this client has no client-id argument and cannot register into another client.
15
+ *
16
+ * Contract this client keeps:
17
+ * - a refusal is thrown as an AgentRegistryError carrying the server's `code` AS-IS (VALIDATION_ERROR, OWNER_NOT_ELIGIBLE,
18
+ * OWNER_NOT_MEMBER, AGENT_NAME_TAKEN, AUDIT_WRITE_FAILED_NOT_APPLIED, MISSING_CLIENT_ID, UNAUTHORIZED, ...). It is never
19
+ * re-worded, mapped, or swallowed.
20
+ * - NO RETRY, of any status. A 409 AGENT_NAME_TAKEN means the name exists; sending it again can only repeat the answer, and a
21
+ * register that timed out may have been applied, so the caller (who can read the list) decides, not this client.
22
+ * - no validation of the body before sending: the server's validation is the authority and its code is what the caller gets.
23
+ * - the bearer never follows a redirect (bearerFetch) and is never put in an error, a message or a log line.
24
+ *
25
+ * A newly registered agent is PENDING until an admin activates it (PAY-2069); its first mint is refused AGENT_PENDING.
26
+ */
27
+ const bearer_fetch_1 = require("../lib/bearer-fetch");
28
+ const idp_url_guard_1 = require("../lib/idp-url-guard");
29
+ /** Core PayEzEndpoints.AgentTokens.AllowedHostClasses (PayEzEndpoints.cs:187,194,228). The server is the authority; this is for typing only. */
30
+ exports.AGENT_HOST_CLASSES = ['rig_pane', 'contractor'];
31
+ class AgentRegistryError extends Error {
32
+ code;
33
+ /** HTTP status; 0 when no response arrived. */
34
+ status;
35
+ requestId;
36
+ details;
37
+ constructor(code, message, status, extra) {
38
+ super(message);
39
+ this.name = 'AgentRegistryError';
40
+ this.code = code;
41
+ this.status = status;
42
+ this.requestId = extra?.requestId;
43
+ this.details = extra?.details;
44
+ }
45
+ }
46
+ exports.AgentRegistryError = AgentRegistryError;
47
+ function createAgentRegistryClient(options) {
48
+ (0, idp_url_guard_1.assertAbsoluteIdpUrl)(options.baseUrl);
49
+ const base = options.baseUrl.replace(/\/+$/, '');
50
+ const send = options.fetch ?? ((input, init) => (0, bearer_fetch_1.bearerFetch)(input, init));
51
+ async function call(path, init) {
52
+ const token = typeof options.accessToken === 'function' ? await options.accessToken() : options.accessToken;
53
+ let res;
54
+ try {
55
+ res = await send(`${base}${path}`, {
56
+ method: init.method,
57
+ headers: {
58
+ Accept: 'application/json',
59
+ ...(init.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
60
+ Authorization: `Bearer ${token}`,
61
+ },
62
+ ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
63
+ cache: 'no-store',
64
+ });
65
+ }
66
+ catch {
67
+ // Fixed text: a transport error's own message can carry the URL or headers.
68
+ // A POST that failed in transit may still have been applied (the server can answer after the client gave up), so for register the text says the
69
+ // outcome is unknown and to read the list; it is never retried here. A GET changes nothing, so it keeps the plain text.
70
+ throw new AgentRegistryError('NETWORK_ERROR', init.method === 'POST'
71
+ ? 'The identity service could not be reached; the outcome is unknown, so read the list before registering again'
72
+ : 'The identity service could not be reached', 0);
73
+ }
74
+ let json = null;
75
+ try {
76
+ json = await res.json();
77
+ }
78
+ catch {
79
+ json = null;
80
+ }
81
+ if (!res.ok) {
82
+ const e = json?.error;
83
+ throw new AgentRegistryError(typeof e?.code === 'string' ? e.code : `HTTP_${res.status}`, typeof e?.message === 'string' ? e.message : `The identity service answered ${res.status}`, res.status, { requestId: json?.request_id ?? e?.support?.request_id, details: e?.details });
84
+ }
85
+ const data = json?.data;
86
+ if (json?.success === false || data == null || typeof data !== 'object') {
87
+ throw new AgentRegistryError('BAD_RESPONSE', 'The identity service answered without the expected data', res.status);
88
+ }
89
+ return data;
90
+ }
91
+ return {
92
+ register: (input) => call('/api/client/agents', {
93
+ method: 'POST',
94
+ // Exactly the three fields the door binds; nothing else is forwarded.
95
+ body: { user_name: input.user_name, owner_user_id: input.owner_user_id, host_class: input.host_class },
96
+ }),
97
+ list: () => call('/api/client/agents', { method: 'GET' }),
98
+ get: (id) => call(`/api/client/agents/${encodeURIComponent(String(id))}`, { method: 'GET' }),
99
+ };
100
+ }
@@ -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.4",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -810,6 +810,11 @@
810
810
  "require": "./dist/hooks/useViabilitySession.js",
811
811
  "default": "./dist/hooks/useViabilitySession.js"
812
812
  },
813
+ "./agents": {
814
+ "types": "./dist/agents/index.d.ts",
815
+ "require": "./dist/agents/index.js",
816
+ "default": "./dist/agents/index.js"
817
+ },
813
818
  "./vibe": {
814
819
  "types": "./dist/vibe/index.d.ts",
815
820
  "require": "./dist/vibe/index.js",
@@ -0,0 +1 @@
1
+ export * from './registry';
@@ -0,0 +1,179 @@
1
+ /**
2
+ * REG-002 (Keel Registry): the SDK half of "Registration API and SDK".
3
+ *
4
+ * A typed client over the tenant-admin agent door in PayEz-Core:
5
+ * PayEz.Apis/PayEz.External.Id.Api/Controllers/ClientAgentsController.cs @ 22d44a34c
6
+ * POST api/client/agents Register (:332-367) body { user_name, owner_user_id, host_class } -> 201 { item, created, audited }
7
+ * GET api/client/agents List (:92-107) -> { client_id, demo_token_ready, items, apps }
8
+ * GET api/client/agents/{id} Get (:126-133) -> { item, apps } | 404
9
+ * Gate: payez_admin / idp_admin_ui_graft_admin with completed MFA (:39-40). The client comes from the TOKEN, never from the
10
+ * body, so this client has no client-id argument and cannot register into another client.
11
+ *
12
+ * Contract this client keeps:
13
+ * - a refusal is thrown as an AgentRegistryError carrying the server's `code` AS-IS (VALIDATION_ERROR, OWNER_NOT_ELIGIBLE,
14
+ * OWNER_NOT_MEMBER, AGENT_NAME_TAKEN, AUDIT_WRITE_FAILED_NOT_APPLIED, MISSING_CLIENT_ID, UNAUTHORIZED, ...). It is never
15
+ * re-worded, mapped, or swallowed.
16
+ * - NO RETRY, of any status. A 409 AGENT_NAME_TAKEN means the name exists; sending it again can only repeat the answer, and a
17
+ * register that timed out may have been applied, so the caller (who can read the list) decides, not this client.
18
+ * - no validation of the body before sending: the server's validation is the authority and its code is what the caller gets.
19
+ * - the bearer never follows a redirect (bearerFetch) and is never put in an error, a message or a log line.
20
+ *
21
+ * A newly registered agent is PENDING until an admin activates it (PAY-2069); its first mint is refused AGENT_PENDING.
22
+ */
23
+ import { bearerFetch } from '../lib/bearer-fetch';
24
+ import { assertAbsoluteIdpUrl } from '../lib/idp-url-guard';
25
+
26
+ /** Core PayEzEndpoints.AgentTokens.AllowedHostClasses (PayEzEndpoints.cs:187,194,228). The server is the authority; this is for typing only. */
27
+ export const AGENT_HOST_CLASSES = ['rig_pane', 'contractor'] as const;
28
+ export type AgentHostClass = (typeof AGENT_HOST_CLASSES)[number];
29
+
30
+ export interface RegisterAgentInput {
31
+ /** 3-64 chars, letters/digits/'.'/'_'/'-', starting with a letter or digit; unique across the IdP. */
32
+ user_name: string;
33
+ /** A person (user id) who is an approved, active member of this client. */
34
+ owner_user_id: number;
35
+ host_class: AgentHostClass;
36
+ }
37
+
38
+ export interface AgentRecord {
39
+ id: number;
40
+ user_name: string;
41
+ owner: { kind: 'person'; user_id: number; display_name: string | null; approved: boolean } | null;
42
+ status: { code: string; reason: string | null };
43
+ host_class: string | null;
44
+ agent_profile_id: number | null;
45
+ }
46
+
47
+ export interface MintableApp {
48
+ id: number;
49
+ name: string;
50
+ allowed_scopes: unknown;
51
+ }
52
+
53
+ export interface RegisterAgentResult {
54
+ item: AgentRecord | null;
55
+ created: boolean;
56
+ audited: boolean;
57
+ }
58
+
59
+ export interface ListAgentsResult {
60
+ client_id: number;
61
+ demo_token_ready: boolean | null;
62
+ items: AgentRecord[];
63
+ apps: MintableApp[];
64
+ }
65
+
66
+ export interface GetAgentResult {
67
+ item: AgentRecord;
68
+ apps: MintableApp[];
69
+ }
70
+
71
+ /** Codes this door is known to answer. `string` stays in the union: an unknown code is surfaced, not hidden. */
72
+ export type AgentRegistryErrorCode =
73
+ | 'VALIDATION_ERROR'
74
+ | 'OWNER_NOT_ELIGIBLE'
75
+ | 'OWNER_NOT_MEMBER'
76
+ | 'AGENT_NAME_TAKEN'
77
+ | 'AUDIT_WRITE_FAILED_NOT_APPLIED'
78
+ | 'MISSING_CLIENT_ID'
79
+ | 'UNAUTHORIZED'
80
+ | 'IDP_UNREACHABLE'
81
+ | 'NETWORK_ERROR'
82
+ | 'BAD_RESPONSE'
83
+ | (string & {});
84
+
85
+ export class AgentRegistryError extends Error {
86
+ readonly code: AgentRegistryErrorCode;
87
+ /** HTTP status; 0 when no response arrived. */
88
+ readonly status: number;
89
+ readonly requestId?: string;
90
+ readonly details?: unknown;
91
+
92
+ constructor(code: AgentRegistryErrorCode, message: string, status: number, extra?: { requestId?: string; details?: unknown }) {
93
+ super(message);
94
+ this.name = 'AgentRegistryError';
95
+ this.code = code;
96
+ this.status = status;
97
+ this.requestId = extra?.requestId;
98
+ this.details = extra?.details;
99
+ }
100
+ }
101
+
102
+ export interface AgentRegistryClientOptions {
103
+ /** Absolute origin of the identity service (e.g. IDP_URL). */
104
+ baseUrl: string;
105
+ /** A tenant-admin access token (completed MFA), or a function that returns one. */
106
+ accessToken: string | (() => string | Promise<string>);
107
+ /** Override for tests; defaults to bearerFetch (global fetch, never follows a redirect). */
108
+ fetch?: (input: string, init: RequestInit) => Promise<Response>;
109
+ }
110
+
111
+ export interface AgentRegistryClient {
112
+ register(input: RegisterAgentInput): Promise<RegisterAgentResult>;
113
+ list(): Promise<ListAgentsResult>;
114
+ get(id: number): Promise<GetAgentResult>;
115
+ }
116
+
117
+ export function createAgentRegistryClient(options: AgentRegistryClientOptions): AgentRegistryClient {
118
+ assertAbsoluteIdpUrl(options.baseUrl);
119
+ const base = options.baseUrl.replace(/\/+$/, '');
120
+ const send = options.fetch ?? ((input: string, init: RequestInit) => bearerFetch(input, init));
121
+
122
+ async function call<T>(path: string, init: { method: 'GET' | 'POST'; body?: unknown }): Promise<T> {
123
+ const token = typeof options.accessToken === 'function' ? await options.accessToken() : options.accessToken;
124
+ let res: Response;
125
+ try {
126
+ res = await send(`${base}${path}`, {
127
+ method: init.method,
128
+ headers: {
129
+ Accept: 'application/json',
130
+ ...(init.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
131
+ Authorization: `Bearer ${token}`,
132
+ },
133
+ ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
134
+ cache: 'no-store',
135
+ });
136
+ } catch {
137
+ // Fixed text: a transport error's own message can carry the URL or headers.
138
+ // A POST that failed in transit may still have been applied (the server can answer after the client gave up), so for register the text says the
139
+ // outcome is unknown and to read the list; it is never retried here. A GET changes nothing, so it keeps the plain text.
140
+ throw new AgentRegistryError(
141
+ 'NETWORK_ERROR',
142
+ init.method === 'POST'
143
+ ? 'The identity service could not be reached; the outcome is unknown, so read the list before registering again'
144
+ : 'The identity service could not be reached',
145
+ 0,
146
+ );
147
+ }
148
+
149
+ let json: any = null;
150
+ try { json = await res.json(); } catch { json = null; }
151
+
152
+ if (!res.ok) {
153
+ const e = json?.error;
154
+ throw new AgentRegistryError(
155
+ typeof e?.code === 'string' ? e.code : `HTTP_${res.status}`,
156
+ typeof e?.message === 'string' ? e.message : `The identity service answered ${res.status}`,
157
+ res.status,
158
+ { requestId: json?.request_id ?? e?.support?.request_id, details: e?.details },
159
+ );
160
+ }
161
+
162
+ const data = json?.data;
163
+ if (json?.success === false || data == null || typeof data !== 'object') {
164
+ throw new AgentRegistryError('BAD_RESPONSE', 'The identity service answered without the expected data', res.status);
165
+ }
166
+ return data as T;
167
+ }
168
+
169
+ return {
170
+ register: (input) =>
171
+ call<RegisterAgentResult>('/api/client/agents', {
172
+ method: 'POST',
173
+ // Exactly the three fields the door binds; nothing else is forwarded.
174
+ body: { user_name: input.user_name, owner_user_id: input.owner_user_id, host_class: input.host_class },
175
+ }),
176
+ list: () => call<ListAgentsResult>('/api/client/agents', { method: 'GET' }),
177
+ get: (id) => call<GetAgentResult>(`/api/client/agents/${encodeURIComponent(String(id))}`, { method: 'GET' }),
178
+ };
179
+ }
@@ -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');