@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 +27 -0
- package/dist/agents/index.d.ts +1 -0
- package/dist/agents/index.js +17 -0
- package/dist/agents/registry.d.ts +73 -0
- package/dist/agents/registry.js +100 -0
- package/dist/api-handlers/admin/audit.js +13 -21
- package/dist/api-handlers/admin/sessions.js +59 -45
- package/dist/api-handlers/admin/stats.js +1 -1
- package/dist/api-handlers/admin/users.js +15 -23
- package/dist/api-handlers/admin/vibe-data.d.ts +5 -0
- package/dist/api-handlers/admin/vibe-data.js +5 -0
- package/dist/vibe/client.d.ts +1 -1
- package/dist/vibe/client.js +2 -1
- package/dist/vibe/filters.d.ts +36 -0
- package/dist/vibe/filters.js +47 -0
- package/dist/vibe/generic.js +2 -1
- package/package.json +6 -1
- package/src/agents/index.ts +1 -0
- package/src/agents/registry.ts +179 -0
- package/src/api-handlers/admin/audit.ts +12 -21
- package/src/api-handlers/admin/sessions.ts +65 -51
- package/src/api-handlers/admin/stats.ts +1 -1
- package/src/api-handlers/admin/users.ts +13 -24
- package/src/api-handlers/admin/vibe-data.ts +5 -0
- package/src/vibe/client.ts +4 -3
- package/src/vibe/filters.ts +52 -0
- package/src/vibe/generic.ts +2 -1
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:
|
|
145
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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:
|
|
160
|
-
|
|
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
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
274
|
-
for (
|
|
275
|
-
const
|
|
276
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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 (
|
|
285
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
143
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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: '
|
|
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: '
|
|
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 }) {
|
package/dist/vibe/client.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/vibe/client.js
CHANGED
|
@@ -331,7 +331,8 @@ class VibeTableDelegate {
|
|
|
331
331
|
*/
|
|
332
332
|
async update(options) {
|
|
333
333
|
const endpoint = this.tableEndpoint(options.where.id);
|
|
334
|
-
|
|
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
|
+
}
|
package/dist/vibe/generic.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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:
|
|
135
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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:
|
|
158
|
-
|
|
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
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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: '
|
|
288
|
+
method: 'POST',
|
|
311
289
|
body: {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
133
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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: '
|
|
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: '
|
|
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(
|
package/src/vibe/client.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/vibe/generic.ts
CHANGED
|
@@ -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
|
-
|
|
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');
|