@memberjunction/server 5.45.0 → 5.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/magicLink/MagicLinkService.d.ts +7 -0
- package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.js +13 -3
- package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
- package/dist/auth/magicLink/magicLinkCore.d.ts +38 -1
- package/dist/auth/magicLink/magicLinkCore.d.ts.map +1 -1
- package/dist/auth/magicLink/magicLinkCore.js +42 -19
- package/dist/auth/magicLink/magicLinkCore.js.map +1 -1
- package/dist/generated/generated.d.ts +107 -0
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +580 -0
- package/dist/generated/generated.js.map +1 -1
- package/dist/resolvers/MergeRecordsResolver.d.ts +2 -2
- package/dist/resolvers/MergeRecordsResolver.d.ts.map +1 -1
- package/dist/resolvers/MergeRecordsResolver.js +15 -5
- package/dist/resolvers/MergeRecordsResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts +9 -0
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +6 -1
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/package.json +88 -88
- package/src/__tests__/RunAIAgentResolver.streaming.test.ts +128 -0
- package/src/__tests__/magicLink.test.ts +53 -3
- package/src/auth/magicLink/MagicLinkService.ts +13 -3
- package/src/auth/magicLink/magicLinkCore.ts +48 -20
- package/src/generated/generated.ts +406 -1
- package/src/resolvers/MergeRecordsResolver.ts +18 -5
- package/src/resolvers/RunAIAgentResolver.ts +16 -4
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Wire-shape tests for RunAIAgentResolver's streaming callback.
|
|
3
|
+
*
|
|
4
|
+
* The conversation client (ConversationsRuntime's ConversationStreaming) parses the
|
|
5
|
+
* exact JSON this resolver publishes to the PushStatusUpdates topic — in particular
|
|
6
|
+
* `data.type === 'streaming'` and `data.streaming.{content,isPartial,kind}`. These
|
|
7
|
+
* tests pin the published shape so the server and client can't silently drift
|
|
8
|
+
* (the client's own parsing of this same shape is pinned in
|
|
9
|
+
* packages/ConversationsRuntime/src/__tests__/ConversationStreaming.test.ts).
|
|
10
|
+
*/
|
|
11
|
+
import 'reflect-metadata'; // must precede any type-graphql decorator evaluation
|
|
12
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
13
|
+
import type { PubSubEngine } from 'type-graphql';
|
|
14
|
+
|
|
15
|
+
// type-graphql's real decorators need emitDecoratorMetadata to infer field types
|
|
16
|
+
// (`@Field() success: boolean`), which vitest's esbuild transform does not emit.
|
|
17
|
+
// The GraphQL schema is irrelevant here — no-op every decorator so the resolver
|
|
18
|
+
// module can be imported and its streaming callback exercised directly.
|
|
19
|
+
vi.mock('type-graphql', () => {
|
|
20
|
+
const decoratorFactory = (..._args: unknown[]) => (..._decorated: unknown[]) => undefined;
|
|
21
|
+
const exportNames = [
|
|
22
|
+
'Resolver', 'Query', 'Mutation', 'Subscription', 'Arg', 'Args', 'ArgsType', 'Ctx', 'Root',
|
|
23
|
+
'Info', 'Field', 'FieldResolver', 'ObjectType', 'InputType', 'InterfaceType', 'Authorized',
|
|
24
|
+
'UseMiddleware', 'Extensions', 'Directive', 'ID', 'Int', 'Float', 'GraphQLISODateTime',
|
|
25
|
+
'GraphQLTimestamp', 'registerEnumType', 'createMethodDecorator', 'createParamDecorator',
|
|
26
|
+
'buildSchema', 'buildSchemaSync', 'PubSub',
|
|
27
|
+
];
|
|
28
|
+
return Object.fromEntries(exportNames.map((name) => [name, decoratorFactory]));
|
|
29
|
+
});
|
|
30
|
+
import type { AgentExecutionStreamingCallback } from '@memberjunction/ai-core-plus';
|
|
31
|
+
|
|
32
|
+
import { RunAIAgentResolver } from '../resolvers/RunAIAgentResolver.js';
|
|
33
|
+
import type { UserPayload } from '../types.js';
|
|
34
|
+
|
|
35
|
+
/** Access the private factory without widening the class's public API. */
|
|
36
|
+
interface StreamingCallbackFactory {
|
|
37
|
+
createStreamingCallback(
|
|
38
|
+
pubSub: PubSubEngine,
|
|
39
|
+
sessionId: string,
|
|
40
|
+
userPayload: UserPayload,
|
|
41
|
+
agentRunRef: { current: unknown }
|
|
42
|
+
): AgentExecutionStreamingCallback;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildHarness() {
|
|
46
|
+
const publish = vi.fn().mockResolvedValue(undefined);
|
|
47
|
+
const pubSub = { publish } as unknown as PubSubEngine;
|
|
48
|
+
const userPayload = { sessionId: 'session-1' } as UserPayload;
|
|
49
|
+
const agentRunRef = {
|
|
50
|
+
current: {
|
|
51
|
+
ID: 'run-1',
|
|
52
|
+
GetAll: () => ({ ID: 'run-1', ConversationDetailID: 'detail-1', Agent: 'Betty' }),
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
const resolver = new RunAIAgentResolver() as unknown as StreamingCallbackFactory;
|
|
56
|
+
const callback = resolver.createStreamingCallback(pubSub, 'session-1', userPayload, agentRunRef);
|
|
57
|
+
return { publish, callback };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Parse the published envelope back out of the pubSub.publish mock. */
|
|
61
|
+
function publishedData(publish: ReturnType<typeof vi.fn>): Record<string, unknown> {
|
|
62
|
+
expect(publish).toHaveBeenCalledOnce();
|
|
63
|
+
const envelope = publish.mock.calls[0][1] as { message: string };
|
|
64
|
+
const parsed = JSON.parse(envelope.message) as Record<string, unknown>;
|
|
65
|
+
expect(parsed.type).toBe('StreamingContent');
|
|
66
|
+
expect(parsed.resolver).toBe('RunAIAgentResolver');
|
|
67
|
+
return parsed.data as Record<string, unknown>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('RunAIAgentResolver.createStreamingCallback', () => {
|
|
71
|
+
it('publishes the streaming wire shape the conversation client parses', () => {
|
|
72
|
+
const { publish, callback } = buildHarness();
|
|
73
|
+
|
|
74
|
+
callback({ content: 'Hel', isComplete: false, stepType: 'prompt', modelName: 'gpt-x', kind: 'final-response' });
|
|
75
|
+
|
|
76
|
+
const data = publishedData(publish);
|
|
77
|
+
expect(data.type).toBe('streaming');
|
|
78
|
+
expect(data.agentRunId).toBe('run-1');
|
|
79
|
+
expect((data.agentRun as Record<string, unknown>).ConversationDetailID).toBe('detail-1');
|
|
80
|
+
expect(data.streaming).toMatchObject({
|
|
81
|
+
content: 'Hel',
|
|
82
|
+
isPartial: true,
|
|
83
|
+
stepName: 'prompt',
|
|
84
|
+
kind: 'final-response',
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('maps isComplete=true to isPartial=false on the final chunk', () => {
|
|
89
|
+
const { publish, callback } = buildHarness();
|
|
90
|
+
|
|
91
|
+
callback({ content: '', isComplete: true, kind: 'final-response' });
|
|
92
|
+
|
|
93
|
+
const data = publishedData(publish);
|
|
94
|
+
expect((data.streaming as Record<string, unknown>).isPartial).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('passes an absent kind through untouched — unmarked raw streams must stay unrendered', () => {
|
|
98
|
+
// Guard rail: if the resolver ever defaults unmarked chunks to a renderable
|
|
99
|
+
// kind (e.g. `chunk.kind ?? 'final-response'` left in from local testing),
|
|
100
|
+
// every Loop agent's raw JSON envelope would render into chat bubbles.
|
|
101
|
+
// This test fails the moment such a fallback exists.
|
|
102
|
+
const { publish, callback } = buildHarness();
|
|
103
|
+
|
|
104
|
+
callback({ content: '{"taskComplete":', isComplete: false, stepType: 'prompt' });
|
|
105
|
+
|
|
106
|
+
const data = publishedData(publish);
|
|
107
|
+
expect((data.streaming as Record<string, unknown>).kind).toBeUndefined();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('does not publish when no agent run is available yet', () => {
|
|
111
|
+
const publish = vi.fn();
|
|
112
|
+
const pubSub = { publish } as unknown as PubSubEngine;
|
|
113
|
+
const resolver = new RunAIAgentResolver() as unknown as StreamingCallbackFactory;
|
|
114
|
+
const callback = resolver.createStreamingCallback(
|
|
115
|
+
pubSub,
|
|
116
|
+
'session-1',
|
|
117
|
+
{ sessionId: 'session-1' } as UserPayload,
|
|
118
|
+
{ current: null }
|
|
119
|
+
);
|
|
120
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
121
|
+
try {
|
|
122
|
+
callback({ content: 'early', isComplete: false });
|
|
123
|
+
expect(publish).not.toHaveBeenCalled();
|
|
124
|
+
} finally {
|
|
125
|
+
consoleSpy.mockRestore();
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import jwt from 'jsonwebtoken';
|
|
3
|
-
import { createPublicKey } from 'node:crypto';
|
|
3
|
+
import { createPublicKey, type JsonWebKey } from 'node:crypto';
|
|
4
4
|
import {
|
|
5
5
|
generateRawToken,
|
|
6
6
|
hashToken,
|
|
@@ -228,6 +228,56 @@ describe('magic-link core', () => {
|
|
|
228
228
|
});
|
|
229
229
|
});
|
|
230
230
|
|
|
231
|
+
describe('buildConsumeInviteSQL (postgresql dialect)', () => {
|
|
232
|
+
// PG uses `schema.table` (unquoted — PostgreSQLDataProvider.ExecuteSQL auto-quotes
|
|
233
|
+
// the PascalCase identifiers). SS uses the bracket-quoted `[schema].[table]` form.
|
|
234
|
+
const pgTable = '__mj.MagicLinkInvite';
|
|
235
|
+
const pgSql = buildConsumeInviteSQL(pgTable, 'postgresql');
|
|
236
|
+
|
|
237
|
+
it('targets the supplied unquoted schema.table', () => {
|
|
238
|
+
expect(pgSql).toContain(`UPDATE ${pgTable} `);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('uses UPDATE … RETURNING as the atomic single-use gate (no OUTPUT/DECLARE table var)', () => {
|
|
242
|
+
expect(pgSql).toContain('RETURNING ID;');
|
|
243
|
+
expect(pgSql).not.toContain('OUTPUT');
|
|
244
|
+
expect(pgSql).not.toContain('DECLARE');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('binds the invite ID as PG positional param $1, not T-SQL @p0 (injection-safe)', () => {
|
|
248
|
+
expect(pgSql).toContain('ID = $1');
|
|
249
|
+
expect(pgSql).not.toContain('@p0');
|
|
250
|
+
expect(pgSql).not.toMatch(/@/); // no T-SQL @-anything survives on PG
|
|
251
|
+
expect(pgSql).not.toMatch(/ID = '/);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('uses PG timestamp expressions, not T-SQL SYSUTCDATETIME()', () => {
|
|
255
|
+
expect(pgSql).not.toContain('SYSUTCDATETIME');
|
|
256
|
+
expect(pgSql).toContain("(now() AT TIME ZONE 'utc')");
|
|
257
|
+
expect(pgSql).toContain("ConsumedAt = COALESCE(ConsumedAt, (now() AT TIME ZONE 'utc'))");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('guards atomically on Active + not-exhausted + not-expired (same predicate as SS)', () => {
|
|
261
|
+
const where = pgSql.slice(pgSql.indexOf('WHERE'));
|
|
262
|
+
expect(where).toContain("Status = 'Active'");
|
|
263
|
+
expect(where).toContain('UseCount < MaxUses');
|
|
264
|
+
expect(where).toContain("ExpiresAt > (now() AT TIME ZONE 'utc')");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('increments UseCount and flips Status on the last use (same semantics as SS)', () => {
|
|
268
|
+
expect(pgSql).toContain('UseCount = UseCount + 1');
|
|
269
|
+
expect(pgSql).toContain("Status = CASE WHEN UseCount + 1 >= MaxUses THEN 'Consumed' ELSE Status END");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('rejects a non-whitelisted PG table identifier (defense-in-depth against injection)', () => {
|
|
273
|
+
// Bracket-quoted (SS) form is not a valid PG identifier here.
|
|
274
|
+
expect(() => buildConsumeInviteSQL('[__mj].[MagicLinkInvite]', 'postgresql')).toThrow();
|
|
275
|
+
expect(() => buildConsumeInviteSQL('__mj.MagicLinkInvite; DROP TABLE x;--', 'postgresql')).toThrow();
|
|
276
|
+
expect(() => buildConsumeInviteSQL('__mj.Magic Link', 'postgresql')).toThrow();
|
|
277
|
+
expect(() => buildConsumeInviteSQL(pgTable, 'postgresql')).not.toThrow();
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
231
281
|
describe('canIssueInvites', () => {
|
|
232
282
|
it('always allows Owners, regardless of issuer-role config (case/space-insensitive)', () => {
|
|
233
283
|
expect(canIssueInvites('Owner', [], [])).toBe(true);
|
|
@@ -351,7 +401,7 @@ describe('MagicLinkKeyManager', () => {
|
|
|
351
401
|
expect(header.kid).toBe(jwk.kid);
|
|
352
402
|
|
|
353
403
|
// Full verification against the public key reconstructed from the JWK.
|
|
354
|
-
const publicKey = createPublicKey({ key: jwk as
|
|
404
|
+
const publicKey = createPublicKey({ key: jwk as unknown as JsonWebKey, format: 'jwk' });
|
|
355
405
|
const decoded = jwt.verify(token, publicKey, {
|
|
356
406
|
algorithms: ['RS256'],
|
|
357
407
|
issuer: 'http://localhost:4051',
|
|
@@ -375,7 +425,7 @@ describe('MagicLinkKeyManager', () => {
|
|
|
375
425
|
ttlSeconds: 3600,
|
|
376
426
|
});
|
|
377
427
|
const token = km.Sign(claims);
|
|
378
|
-
const publicKey = createPublicKey({ key: km.GetJWKS().keys[0] as
|
|
428
|
+
const publicKey = createPublicKey({ key: km.GetJWKS().keys[0] as unknown as JsonWebKey, format: 'jwk' });
|
|
379
429
|
|
|
380
430
|
// Flip a character in the payload segment.
|
|
381
431
|
const parts = token.split('.');
|
|
@@ -640,6 +640,13 @@ export class MagicLinkService {
|
|
|
640
640
|
* a single atomic operation. Returns true ONLY when this call updated the row
|
|
641
641
|
* (won the race); concurrent redemptions of a single-use link see 0 rows.
|
|
642
642
|
*
|
|
643
|
+
* Dialect-aware: SQL Server uses an `OUTPUT`-into-table-variable win-detect;
|
|
644
|
+
* PostgreSQL uses `UPDATE … WHERE … RETURNING` (the WHERE guard is itself the
|
|
645
|
+
* atomic single-use gate). The platform is read from the provider itself
|
|
646
|
+
* (`PlatformKey`), not from process env, so this is correct even under a
|
|
647
|
+
* non-default per-connection provider. The invite ID always binds as a
|
|
648
|
+
* parameter (`@p0` / `$1`), never interpolated.
|
|
649
|
+
*
|
|
643
650
|
* Fail-closed: any DB error returns false and the redemption is rejected.
|
|
644
651
|
*/
|
|
645
652
|
private async consumeInvite(invite: MJMagicLinkInviteEntity, provider: DatabaseProviderBase, contextUser: UserInfo): Promise<boolean> {
|
|
@@ -649,9 +656,12 @@ export class MagicLinkService {
|
|
|
649
656
|
LogError(`[MagicLink] Entity metadata for '${INVITE_ENTITY}' not found; cannot consume invite.`);
|
|
650
657
|
return false;
|
|
651
658
|
}
|
|
652
|
-
const
|
|
653
|
-
//
|
|
654
|
-
|
|
659
|
+
const isPg = provider.PlatformKey === 'postgresql';
|
|
660
|
+
// PG identifiers are auto-quoted by PostgreSQLDataProvider.ExecuteSQL, so
|
|
661
|
+
// pass the bare `schema.table`; SQL Server takes the bracket-quoted form.
|
|
662
|
+
const table = isPg ? `${entityInfo.SchemaName}.${entityInfo.BaseTable}` : `[${entityInfo.SchemaName}].[${entityInfo.BaseTable}]`;
|
|
663
|
+
// OUTPUT/RETURNING yields one row iff the WHERE matched — the atomic single-use gate.
|
|
664
|
+
const sql = buildConsumeInviteSQL(table, isPg ? 'postgresql' : 'sqlserver');
|
|
655
665
|
const rows = await provider.ExecuteSQL<{ ID: string }>(sql, [invite.ID], { isMutation: true }, contextUser);
|
|
656
666
|
return Array.isArray(rows) && rows.length === 1;
|
|
657
667
|
} catch (e) {
|
|
@@ -106,35 +106,63 @@ export function evaluateInvite(invite: InviteEvaluationInput, nowMs: number): {
|
|
|
106
106
|
return { ok: true };
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/** SQL dialect for {@link buildConsumeInviteSQL}. */
|
|
110
|
+
export type ConsumeInviteDialect = 'sqlserver' | 'postgresql';
|
|
111
|
+
|
|
112
|
+
/** SQL Server table identifier — bracket-quoted `[schema].[table]` (word chars only). */
|
|
113
|
+
const QUALIFIED_TABLE_PATTERN = /^\[\w+\]\.\[\w+\]$/;
|
|
114
|
+
/** PostgreSQL table identifier — `schema.table` (word chars only); auto-quoted downstream. */
|
|
115
|
+
const QUALIFIED_TABLE_PATTERN_PG = /^\w+\.\w+$/;
|
|
116
|
+
|
|
109
117
|
/**
|
|
110
|
-
* Builds the atomic compare-and-swap UPDATE that consumes one use of an invite
|
|
118
|
+
* Builds the atomic compare-and-swap UPDATE that consumes one use of an invite,
|
|
119
|
+
* in the given SQL dialect.
|
|
111
120
|
*
|
|
112
121
|
* The WHERE clause re-checks every eligibility condition at the DB level, so the
|
|
113
122
|
* increment and the guard are a single atomic operation: concurrent redemptions
|
|
114
|
-
* of a single-use link race on the row and exactly one matches
|
|
115
|
-
*
|
|
116
|
-
*
|
|
123
|
+
* of a single-use link race on the row and exactly one matches. This is what
|
|
124
|
+
* actually enforces single-use — the JS-side `evaluateInvite` is only a friendly
|
|
125
|
+
* pre-check. The matched row's ID is returned to the caller (via `OUTPUT` on SQL
|
|
126
|
+
* Server, `RETURNING` on PostgreSQL) so it can detect a win (exactly one row).
|
|
127
|
+
*
|
|
128
|
+
* The invite ID MUST be bound as a parameter by the caller (`@p0` on SQL Server,
|
|
129
|
+
* `$1` on PostgreSQL) — it is never interpolated into the string, so this builder
|
|
130
|
+
* is injection-safe regardless of the ID's contents.
|
|
117
131
|
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
132
|
+
* **SQL Server** — `OUTPUT` goes `INTO` a table variable (not a bare OUTPUT):
|
|
133
|
+
* SQL Server forbids a bare OUTPUT clause on a table that has enabled triggers,
|
|
134
|
+
* and CodeGen adds an `__mj_UpdatedAt` trigger to every MJ table. The trailing
|
|
135
|
+
* SELECT returns the matched row(s) regardless of trigger behavior.
|
|
121
136
|
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
137
|
+
* **PostgreSQL** — the `UPDATE … WHERE` guard IS itself the atomic single-use
|
|
138
|
+
* gate: the row is locked for the UPDATE's duration, so concurrent redemptions
|
|
139
|
+
* serialize and only the first matching `Status='Active' AND UseCount < MaxUses`
|
|
140
|
+
* wins; `RETURNING` yields the row iff the guard matched (equivalent to the SS
|
|
141
|
+
* `OUTPUT`-into-table win-detect). PascalCase identifiers are auto-quoted by
|
|
142
|
+
* `PostgreSQLDataProvider.ExecuteSQL`, so the table is passed unquoted (`schema.table`).
|
|
127
143
|
*
|
|
128
|
-
* `qualifiedTable` is asserted to
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
144
|
+
* `qualifiedTable` is asserted to match the dialect's whitelist pattern before
|
|
145
|
+
* interpolation. The caller derives it from `EntityInfo` (never user input), so
|
|
146
|
+
* this is defense-in-depth: even a future careless caller cannot turn this into
|
|
147
|
+
* an injection vector — a non-conforming table throws.
|
|
132
148
|
*
|
|
133
|
-
* @param qualifiedTable
|
|
149
|
+
* @param qualifiedTable `[schema].[table]` (sqlserver) or `schema.table` (postgresql) for MagicLinkInvite
|
|
150
|
+
* @param dialect target SQL dialect (defaults to `'sqlserver'`)
|
|
134
151
|
*/
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
152
|
+
export function buildConsumeInviteSQL(qualifiedTable: string, dialect: ConsumeInviteDialect = 'sqlserver'): string {
|
|
153
|
+
if (dialect === 'postgresql') {
|
|
154
|
+
if (!QUALIFIED_TABLE_PATTERN_PG.test(qualifiedTable)) {
|
|
155
|
+
throw new Error(`buildConsumeInviteSQL: refusing to build SQL for non-whitelisted table identifier '${qualifiedTable}'.`);
|
|
156
|
+
}
|
|
157
|
+
return (
|
|
158
|
+
`UPDATE ${qualifiedTable} ` +
|
|
159
|
+
`SET UseCount = UseCount + 1, ` +
|
|
160
|
+
`ConsumedAt = COALESCE(ConsumedAt, (now() AT TIME ZONE 'utc')), ` +
|
|
161
|
+
`Status = CASE WHEN UseCount + 1 >= MaxUses THEN 'Consumed' ELSE Status END ` +
|
|
162
|
+
`WHERE ID = $1 AND Status = 'Active' AND UseCount < MaxUses AND ExpiresAt > (now() AT TIME ZONE 'utc') ` +
|
|
163
|
+
`RETURNING ID;`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
138
166
|
if (!QUALIFIED_TABLE_PATTERN.test(qualifiedTable)) {
|
|
139
167
|
throw new Error(`buildConsumeInviteSQL: refusing to build SQL for non-whitelisted table identifier '${qualifiedTable}'.`);
|
|
140
168
|
}
|