@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.
@@ -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 object, format: 'jwk' });
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 object, format: 'jwk' });
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 table = `[${entityInfo.SchemaName}].[${entityInfo.BaseTable}]`;
653
- // OUTPUT returns one row iff the WHERE matched — the atomic single-use gate.
654
- const sql = buildConsumeInviteSQL(table);
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 (the matched row
115
- * is returned via OUTPUT). This is what actually enforces single-use — the
116
- * JS-side `evaluateInvite` is only a friendly pre-check.
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
- * The invite ID MUST be bound as parameter `@p0` by the caller it is never
119
- * interpolated into the string, so this builder is injection-safe regardless of
120
- * the ID's contents.
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
- * OUTPUT goes `INTO` a table variable (not a bare OUTPUT): SQL Server forbids a
123
- * bare OUTPUT clause on a table that has enabled triggers, and CodeGen adds an
124
- * `__mj_UpdatedAt` trigger to every MJ table. The trailing SELECT returns exactly
125
- * the matched row(s) so the caller can detect a win (exactly one row) regardless
126
- * of trigger behavior.
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 be a bracket-quoted `[schema].[table]` (word
129
- * chars only) before interpolation. The caller derives it from `EntityInfo`
130
- * (never user input), so this is defense-in-depth: even a future careless caller
131
- * cannot turn this into an injection vector — a non-conforming table throws.
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 bracket-quoted `[schema].[table]` for MagicLinkInvite
149
+ * @param qualifiedTable `[schema].[table]` (sqlserver) or `schema.table` (postgresql) for MagicLinkInvite
150
+ * @param dialect target SQL dialect (defaults to `'sqlserver'`)
134
151
  */
135
- const QUALIFIED_TABLE_PATTERN = /^\[\w+\]\.\[\w+\]$/;
136
-
137
- export function buildConsumeInviteSQL(qualifiedTable: string): string {
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
  }