@memberjunction/server 6.1.3 → 6.1.5
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 +30 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/auth/magicLink/MagicLinkService.d.ts +17 -15
- package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.js +49 -35
- package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
- package/dist/auth/magicLink/magicLinkCore.d.ts +14 -33
- package/dist/auth/magicLink/magicLinkCore.d.ts.map +1 -1
- package/dist/auth/magicLink/magicLinkCore.js +20 -52
- package/dist/auth/magicLink/magicLinkCore.js.map +1 -1
- package/dist/config.d.ts +53 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +23 -0
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +11 -23
- package/dist/context.js.map +1 -1
- package/dist/generic/CacheInvalidationResolver.d.ts +87 -3
- package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
- package/dist/generic/CacheInvalidationResolver.js +118 -3
- package/dist/generic/CacheInvalidationResolver.js.map +1 -1
- package/dist/generic/DeleteOptionsInput.d.ts +8 -1
- package/dist/generic/DeleteOptionsInput.d.ts.map +1 -1
- package/dist/generic/DeleteOptionsInput.js +1 -1
- package/dist/generic/DeleteOptionsInput.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +4 -0
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +10 -3
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +49 -47
- package/dist/index.js.map +1 -1
- package/dist/postgresqlCompat.d.ts +8 -0
- package/dist/postgresqlCompat.d.ts.map +1 -0
- package/dist/postgresqlCompat.js +19 -0
- package/dist/postgresqlCompat.js.map +1 -0
- package/dist/postgresqlPoolSettings.d.ts +108 -0
- package/dist/postgresqlPoolSettings.d.ts.map +1 -0
- package/dist/postgresqlPoolSettings.js +134 -0
- package/dist/postgresqlPoolSettings.js.map +1 -0
- package/dist/resolvers/AdhocQueryResolver.d.ts +10 -20
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +54 -174
- package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +44 -6
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +90 -34
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +2 -1
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/adhocTimeout.d.ts +7 -0
- package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
- package/dist/resolvers/adhocTimeout.js +12 -0
- package/dist/resolvers/adhocTimeout.js.map +1 -0
- package/dist/services/TaskGraphActionRunner.d.ts +13 -8
- package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphActionRunner.js +16 -15
- package/dist/services/TaskGraphActionRunner.js.map +1 -1
- package/package.json +92 -92
- package/src/__tests__/AdhocQueryResolver.bugs.test.ts +12 -35
- package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
- package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
- package/src/__tests__/DeleteOptionsInput.wireShape.test.ts +62 -0
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +216 -2
- package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +129 -0
- package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
- package/src/__tests__/cacheInvalidationRecordData.test.ts +177 -0
- package/src/__tests__/magicLink.test.ts +15 -76
- package/src/__tests__/magicLinkService.consume.test.ts +224 -0
- package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
- package/src/__tests__/translateBracketsToPG.test.ts +20 -0
- package/src/auth/magicLink/MagicLinkService.ts +55 -36
- package/src/auth/magicLink/magicLinkCore.ts +19 -56
- package/src/config.ts +23 -0
- package/src/context.ts +11 -24
- package/src/generic/CacheInvalidationResolver.ts +139 -3
- package/src/generic/DeleteOptionsInput.ts +9 -2
- package/src/generic/ResolverBase.ts +11 -3
- package/src/index.ts +55 -48
- package/src/postgresqlCompat.ts +21 -0
- package/src/postgresqlPoolSettings.ts +213 -0
- package/src/resolvers/AdhocQueryResolver.ts +64 -194
- package/src/resolvers/RealtimeClientSessionResolver.ts +93 -35
- package/src/resolvers/RunAIAgentResolver.ts +4 -1
- package/src/resolvers/adhocTimeout.ts +12 -0
- package/src/services/TaskGraphActionRunner.ts +16 -15
- package/dist/resolvers/adhoc-query-helpers.d.ts +0 -27
- package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
- package/dist/resolvers/adhoc-query-helpers.js +0 -42
- package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
- package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
- package/src/resolvers/adhoc-query-helpers.ts +0 -50
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
hashToken,
|
|
7
7
|
evaluateInvite,
|
|
8
8
|
buildSessionClaims,
|
|
9
|
-
|
|
9
|
+
buildConsumeInvitePostgresSQL,
|
|
10
10
|
canIssueInvites,
|
|
11
11
|
isRoleGrantable,
|
|
12
12
|
unionScopes,
|
|
@@ -168,71 +168,13 @@ describe('magic-link core', () => {
|
|
|
168
168
|
});
|
|
169
169
|
});
|
|
170
170
|
|
|
171
|
-
describe('
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
expect(sql).toContain(`UPDATE ${table} `);
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
it('increments UseCount', () => {
|
|
180
|
-
expect(sql).toContain('UseCount = UseCount + 1');
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it('stamps ConsumedAt only the first time (COALESCE preserves an existing value)', () => {
|
|
184
|
-
expect(sql).toContain('ConsumedAt = COALESCE(ConsumedAt, SYSUTCDATETIME())');
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it('flips Status to Consumed exactly when the last use is taken', () => {
|
|
188
|
-
expect(sql).toContain("Status = CASE WHEN UseCount + 1 >= MaxUses THEN 'Consumed' ELSE Status END");
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it('returns the affected row via OUTPUT INTO a table var so the caller can detect a win (exactly one row)', () => {
|
|
192
|
-
// Must be OUTPUT ... INTO, not a bare OUTPUT: SQL Server forbids a bare
|
|
193
|
-
// OUTPUT clause on a table with enabled triggers, and CodeGen adds an
|
|
194
|
-
// __mj_UpdatedAt trigger to every MJ table. Regression guard for that bug.
|
|
195
|
-
expect(sql).toContain('OUTPUT INSERTED.ID INTO @consumed');
|
|
196
|
-
expect(sql).not.toMatch(/OUTPUT INSERTED\.ID(?!\s+INTO)/);
|
|
197
|
-
expect(sql).toContain('SELECT ID FROM @consumed');
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
it('guards atomically on Active + not-exhausted + not-expired — this IS the single-use gate', () => {
|
|
201
|
-
const where = sql.slice(sql.indexOf('WHERE'));
|
|
202
|
-
expect(where).toContain("Status = 'Active'");
|
|
203
|
-
expect(where).toContain('UseCount < MaxUses');
|
|
204
|
-
expect(where).toContain('ExpiresAt > SYSUTCDATETIME()');
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it('binds the invite ID as a parameter, never interpolated (injection-safe)', () => {
|
|
208
|
-
expect(sql).toContain('ID = @p0');
|
|
209
|
-
// The id must not be string-interpolated with quotes around a value.
|
|
210
|
-
expect(sql).not.toMatch(/ID = '/);
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
it('is a self-contained batch: declare table var, update-with-output, select', () => {
|
|
214
|
-
// Intentionally a 3-statement batch (the OUTPUT-INTO requirement). The ID is
|
|
215
|
-
// still parameterized, so the batch carries no interpolated user input.
|
|
216
|
-
expect(sql).toContain('DECLARE @consumed TABLE (ID UNIQUEIDENTIFIER)');
|
|
217
|
-
expect(sql.match(/;/g)?.length).toBe(3);
|
|
218
|
-
expect(sql.trim().endsWith(';')).toBe(true);
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
it('rejects a non-whitelisted table identifier (defense-in-depth against injection)', () => {
|
|
222
|
-
// Only bracket-quoted [schema].[table] of word chars is accepted, even though
|
|
223
|
-
// the caller derives the table from EntityInfo and never from user input.
|
|
224
|
-
expect(() => buildConsumeInviteSQL('__mj.MagicLinkInvite')).toThrow();
|
|
225
|
-
expect(() => buildConsumeInviteSQL('[__mj].[MagicLinkInvite]; DROP TABLE x;--')).toThrow();
|
|
226
|
-
expect(() => buildConsumeInviteSQL('[__mj].[Magic Link]')).toThrow();
|
|
227
|
-
expect(() => buildConsumeInviteSQL(table)).not.toThrow();
|
|
228
|
-
});
|
|
229
|
-
});
|
|
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.
|
|
171
|
+
describe('buildConsumeInvitePostgresSQL', () => {
|
|
172
|
+
// PostgreSQL only: SQL Server consumes through spConsumeMagicLinkInvite (#4753), but PG has no
|
|
173
|
+
// such function and its runtime roles hold table DML, so PG keeps this direct guarded UPDATE.
|
|
174
|
+
// PG uses `schema.table` unquoted — PostgreSQLDataProvider.ExecuteSQL auto-quotes the
|
|
175
|
+
// PascalCase identifiers.
|
|
234
176
|
const pgTable = '__mj.MagicLinkInvite';
|
|
235
|
-
const pgSql =
|
|
177
|
+
const pgSql = buildConsumeInvitePostgresSQL(pgTable);
|
|
236
178
|
|
|
237
179
|
it('targets the supplied unquoted schema.table', () => {
|
|
238
180
|
expect(pgSql).toContain(`UPDATE ${pgTable} `);
|
|
@@ -246,35 +188,32 @@ describe('magic-link core', () => {
|
|
|
246
188
|
|
|
247
189
|
it('binds the invite ID as PG positional param $1, not T-SQL @p0 (injection-safe)', () => {
|
|
248
190
|
expect(pgSql).toContain('ID = $1');
|
|
249
|
-
expect(pgSql).not.
|
|
250
|
-
expect(pgSql).not.toMatch(/@/); // no T-SQL @-anything survives on PG
|
|
191
|
+
expect(pgSql).not.toMatch(/@/);
|
|
251
192
|
expect(pgSql).not.toMatch(/ID = '/);
|
|
252
193
|
});
|
|
253
194
|
|
|
254
195
|
it('uses PG timestamp expressions, not T-SQL SYSUTCDATETIME()', () => {
|
|
255
196
|
expect(pgSql).not.toContain('SYSUTCDATETIME');
|
|
256
|
-
expect(pgSql).toContain("(now() AT TIME ZONE 'utc')");
|
|
257
197
|
expect(pgSql).toContain("ConsumedAt = COALESCE(ConsumedAt, (now() AT TIME ZONE 'utc'))");
|
|
258
198
|
});
|
|
259
199
|
|
|
260
|
-
it('guards atomically on Active + not-exhausted + not-expired (same predicate as
|
|
200
|
+
it('guards atomically on Active + not-exhausted + not-expired (same predicate as spConsumeMagicLinkInvite)', () => {
|
|
261
201
|
const where = pgSql.slice(pgSql.indexOf('WHERE'));
|
|
262
202
|
expect(where).toContain("Status = 'Active'");
|
|
263
203
|
expect(where).toContain('UseCount < MaxUses');
|
|
264
204
|
expect(where).toContain("ExpiresAt > (now() AT TIME ZONE 'utc')");
|
|
265
205
|
});
|
|
266
206
|
|
|
267
|
-
it('increments UseCount and flips Status on the last use (same semantics as
|
|
207
|
+
it('increments UseCount and flips Status on the last use (same semantics as spConsumeMagicLinkInvite)', () => {
|
|
268
208
|
expect(pgSql).toContain('UseCount = UseCount + 1');
|
|
269
209
|
expect(pgSql).toContain("Status = CASE WHEN UseCount + 1 >= MaxUses THEN 'Consumed' ELSE Status END");
|
|
270
210
|
});
|
|
271
211
|
|
|
272
|
-
it('rejects a non-whitelisted
|
|
273
|
-
|
|
274
|
-
expect(() =>
|
|
275
|
-
expect(() =>
|
|
276
|
-
expect(() =>
|
|
277
|
-
expect(() => buildConsumeInviteSQL(pgTable, 'postgresql')).not.toThrow();
|
|
212
|
+
it('rejects a non-whitelisted table identifier (defense-in-depth against injection)', () => {
|
|
213
|
+
expect(() => buildConsumeInvitePostgresSQL('[__mj].[MagicLinkInvite]')).toThrow();
|
|
214
|
+
expect(() => buildConsumeInvitePostgresSQL('__mj.MagicLinkInvite; DROP TABLE x;--')).toThrow();
|
|
215
|
+
expect(() => buildConsumeInvitePostgresSQL('__mj.Magic Link')).toThrow();
|
|
216
|
+
expect(() => buildConsumeInvitePostgresSQL(pgTable)).not.toThrow();
|
|
278
217
|
});
|
|
279
218
|
});
|
|
280
219
|
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// MagicLinkService is the imperative shell — importing it pulls the full
|
|
4
|
+
// data-provider and communication stacks in at module load. Stub those out;
|
|
5
|
+
// @memberjunction/core stays real (Metadata, RunView, LogError).
|
|
6
|
+
vi.mock('@memberjunction/generic-database-provider', () => ({
|
|
7
|
+
UserCache: { Instance: { Users: [] } },
|
|
8
|
+
}));
|
|
9
|
+
vi.mock('@memberjunction/communication-engine', () => ({
|
|
10
|
+
CommunicationEngine: { Instance: {} },
|
|
11
|
+
}));
|
|
12
|
+
vi.mock('@memberjunction/communication-types', () => ({
|
|
13
|
+
Message: class {},
|
|
14
|
+
}));
|
|
15
|
+
vi.mock('../config.js', () => ({
|
|
16
|
+
configInfo: {},
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
import { Metadata, RunView, type DatabaseProviderBase, type IMetadataProvider, type UserInfo } from '@memberjunction/core';
|
|
20
|
+
import type { MJMagicLinkInviteEntity } from '@memberjunction/core-entities';
|
|
21
|
+
import type { MagicLinkConfig } from '../config.js';
|
|
22
|
+
import { MagicLinkService } from '../auth/magicLink/MagicLinkService.js';
|
|
23
|
+
import { MAGIC_LINK_TOKEN_PREFIX, buildConsumeInvitePostgresSQL } from '../auth/magicLink/magicLinkCore.js';
|
|
24
|
+
|
|
25
|
+
type Platform = 'sqlserver' | 'postgresql';
|
|
26
|
+
|
|
27
|
+
/** Provider surface consumeInvite/RedeemInvite touch, with real-shaped dialect behavior. */
|
|
28
|
+
interface FakeProvider {
|
|
29
|
+
PlatformKey: Platform;
|
|
30
|
+
MJCoreSchemaName: string;
|
|
31
|
+
Roles: { ID: string; Name: string }[];
|
|
32
|
+
Applications: { ID: string; Name: string; Path: string }[];
|
|
33
|
+
Dialect: { ProcedureCallSyntax: (schema: string, name: string, params: string[]) => string };
|
|
34
|
+
BuildParameterPlaceholder: (index: number) => string;
|
|
35
|
+
ExecuteSQL: Mock;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function makeProvider(platform: Platform): FakeProvider {
|
|
39
|
+
const isPg = platform === 'postgresql';
|
|
40
|
+
return {
|
|
41
|
+
PlatformKey: platform,
|
|
42
|
+
MJCoreSchemaName: '__mj',
|
|
43
|
+
Roles: [{ ID: 'role-1', Name: 'Magic Link Baseline' }],
|
|
44
|
+
Applications: [],
|
|
45
|
+
Dialect: {
|
|
46
|
+
ProcedureCallSyntax: isPg
|
|
47
|
+
? (s, n, p) => `SELECT * FROM ${s}."${n}"(${p.join(', ')})`
|
|
48
|
+
: (s, n, p) => `EXEC [${s}].[${n}] ${p.join(', ')}`,
|
|
49
|
+
},
|
|
50
|
+
BuildParameterPlaceholder: isPg ? (i) => `$${i + 1}` : (i) => `@p${i}`,
|
|
51
|
+
ExecuteSQL: vi.fn(),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const INVITE_ID = '11111111-2222-3333-4444-555555555555';
|
|
56
|
+
|
|
57
|
+
function makeInvite(): MJMagicLinkInviteEntity {
|
|
58
|
+
return {
|
|
59
|
+
ID: INVITE_ID,
|
|
60
|
+
Status: 'Active',
|
|
61
|
+
UseCount: 0,
|
|
62
|
+
MaxUses: 1,
|
|
63
|
+
ExpiresAt: new Date(Date.now() + 3600_000),
|
|
64
|
+
RoleID: 'role-1',
|
|
65
|
+
CreatedByUserID: 'inviter-1',
|
|
66
|
+
ApplicationID: 'app-1',
|
|
67
|
+
Email: 'g@example.com',
|
|
68
|
+
} as unknown as MJMagicLinkInviteEntity;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Private surface of MagicLinkService the tests drive or stub. */
|
|
72
|
+
interface ServicePrivates {
|
|
73
|
+
consumeInvite: (invite: MJMagicLinkInviteEntity, provider: DatabaseProviderBase, contextUser: UserInfo) => Promise<string>;
|
|
74
|
+
resolveProvisioningContextUser: () => UserInfo | null;
|
|
75
|
+
isInviterActive: (id: string) => boolean;
|
|
76
|
+
recordRedemption: () => Promise<void>;
|
|
77
|
+
provisionUser: () => Promise<{ success: boolean; error?: string }>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe('MagicLinkService.consumeInvite (#4753)', () => {
|
|
81
|
+
let service: MagicLinkService;
|
|
82
|
+
let privates: ServicePrivates;
|
|
83
|
+
let invite: MJMagicLinkInviteEntity;
|
|
84
|
+
const contextUser = { ID: 'ctx-user' } as UserInfo;
|
|
85
|
+
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
service = new MagicLinkService('https://mj.example.com', {} as MagicLinkConfig);
|
|
88
|
+
privates = service as unknown as ServicePrivates;
|
|
89
|
+
invite = makeInvite();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
afterEach(() => {
|
|
93
|
+
Metadata.Provider = null as unknown as IMetadataProvider;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
async function consume(provider: FakeProvider): Promise<string> {
|
|
97
|
+
return privates.consumeInvite(invite, provider as unknown as DatabaseProviderBase, contextUser);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
it('SQL Server: calls spConsumeMagicLinkInvite with the ID bound by name, never interpolated', async () => {
|
|
101
|
+
const provider = makeProvider('sqlserver');
|
|
102
|
+
provider.ExecuteSQL.mockResolvedValue([{ ID: INVITE_ID }]);
|
|
103
|
+
await consume(provider);
|
|
104
|
+
|
|
105
|
+
expect(provider.ExecuteSQL).toHaveBeenCalledTimes(1);
|
|
106
|
+
const [sql, params, options, user] = provider.ExecuteSQL.mock.calls[0];
|
|
107
|
+
expect(sql).toBe('EXEC [__mj].[spConsumeMagicLinkInvite] @ID=@p0');
|
|
108
|
+
expect(params).toEqual([INVITE_ID]);
|
|
109
|
+
expect(options).toEqual(expect.objectContaining({ isMutation: true }));
|
|
110
|
+
expect(user).toBe(contextUser);
|
|
111
|
+
expect(sql).not.toContain(INVITE_ID);
|
|
112
|
+
expect(String(sql).toUpperCase()).not.toContain('UPDATE');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// PG has no spConsumeMagicLinkInvite function (the release-time converter does not emit
|
|
116
|
+
// procedures) and PG's runtime roles hold table DML, so PG keeps the direct guarded UPDATE.
|
|
117
|
+
it('PostgreSQL: runs the guarded UPDATE … RETURNING directly, with the ID bound as $1', async () => {
|
|
118
|
+
const provider = makeProvider('postgresql');
|
|
119
|
+
provider.ExecuteSQL.mockResolvedValue([{ ID: INVITE_ID }]);
|
|
120
|
+
await consume(provider);
|
|
121
|
+
|
|
122
|
+
expect(provider.ExecuteSQL).toHaveBeenCalledTimes(1);
|
|
123
|
+
const [sql, params, options, user] = provider.ExecuteSQL.mock.calls[0];
|
|
124
|
+
expect(sql).toBe(buildConsumeInvitePostgresSQL('__mj.MagicLinkInvite'));
|
|
125
|
+
expect(sql).not.toContain('spConsumeMagicLinkInvite');
|
|
126
|
+
expect(params).toEqual([INVITE_ID]);
|
|
127
|
+
expect(options).toEqual(expect.objectContaining({ isMutation: true }));
|
|
128
|
+
expect(user).toBe(contextUser);
|
|
129
|
+
expect(sql).not.toContain(INVITE_ID);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("PostgreSQL: maps one row to 'won', zero rows to 'lost', and a thrown error to 'failed'", async () => {
|
|
133
|
+
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
134
|
+
const provider = makeProvider('postgresql');
|
|
135
|
+
provider.ExecuteSQL.mockResolvedValueOnce([{ ID: INVITE_ID }]).mockResolvedValueOnce([]).mockRejectedValueOnce(new Error('connection reset'));
|
|
136
|
+
|
|
137
|
+
expect(await consume(provider)).toBe('won');
|
|
138
|
+
expect(await consume(provider)).toBe('lost');
|
|
139
|
+
expect(await consume(provider)).toBe('failed');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("returns 'won' when exactly one row comes back", async () => {
|
|
143
|
+
const provider = makeProvider('sqlserver');
|
|
144
|
+
provider.ExecuteSQL.mockResolvedValue([{ ID: INVITE_ID }]);
|
|
145
|
+
expect(await consume(provider)).toBe('won');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("returns 'lost' when zero rows come back", async () => {
|
|
149
|
+
const provider = makeProvider('sqlserver');
|
|
150
|
+
provider.ExecuteSQL.mockResolvedValue([]);
|
|
151
|
+
expect(await consume(provider)).toBe('lost');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("returns 'failed' and logs the invite ID when ExecuteSQL throws", async () => {
|
|
155
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
156
|
+
const provider = makeProvider('sqlserver');
|
|
157
|
+
provider.ExecuteSQL.mockRejectedValue(new Error("The EXECUTE permission was denied on the object 'spConsumeMagicLinkInvite'"));
|
|
158
|
+
|
|
159
|
+
expect(await consume(provider)).toBe('failed');
|
|
160
|
+
const logged = errorSpy.mock.calls.map((call) => call.map(String).join(' '));
|
|
161
|
+
expect(logged.some((line) => line.includes(INVITE_ID) && line.includes('spConsumeMagicLinkInvite'))).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("returns 'failed' when ExecuteSQL returns a non-array", async () => {
|
|
165
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
166
|
+
const provider = makeProvider('sqlserver');
|
|
167
|
+
provider.ExecuteSQL.mockResolvedValue(undefined);
|
|
168
|
+
|
|
169
|
+
expect(await consume(provider)).toBe('failed');
|
|
170
|
+
const logged = errorSpy.mock.calls.map((call) => call.map(String).join(' '));
|
|
171
|
+
expect(logged.some((line) => line.includes(INVITE_ID))).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('MagicLinkService.RedeemInvite consume outcome mapping (#4753)', () => {
|
|
176
|
+
let service: MagicLinkService;
|
|
177
|
+
let provider: FakeProvider;
|
|
178
|
+
let provisionUser: Mock;
|
|
179
|
+
const contextUser = { ID: 'ctx-user' } as UserInfo;
|
|
180
|
+
const rawToken = MAGIC_LINK_TOKEN_PREFIX + 'x';
|
|
181
|
+
|
|
182
|
+
beforeEach(() => {
|
|
183
|
+
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
184
|
+
provider = makeProvider('sqlserver');
|
|
185
|
+
Metadata.Provider = provider as unknown as IMetadataProvider;
|
|
186
|
+
|
|
187
|
+
service = new MagicLinkService('https://mj.example.com', {} as MagicLinkConfig);
|
|
188
|
+
const privates = service as unknown as ServicePrivates;
|
|
189
|
+
vi.spyOn(RunView.prototype, 'RunView').mockResolvedValue({ Success: true, Results: [makeInvite()] } as Awaited<ReturnType<RunView['RunView']>>);
|
|
190
|
+
vi.spyOn(privates, 'resolveProvisioningContextUser').mockReturnValue(contextUser);
|
|
191
|
+
vi.spyOn(privates, 'isInviterActive').mockReturnValue(true);
|
|
192
|
+
vi.spyOn(privates, 'recordRedemption').mockResolvedValue(undefined);
|
|
193
|
+
provisionUser = vi.fn().mockResolvedValue({ success: false, error: 'stop-here' });
|
|
194
|
+
privates.provisionUser = provisionUser;
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
afterEach(() => {
|
|
198
|
+
Metadata.Provider = null as unknown as IMetadataProvider;
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('RedeemInvite maps a failed consume to server_error, not consumed, and never provisions', async () => {
|
|
202
|
+
provider.ExecuteSQL.mockRejectedValue(new Error("The EXECUTE permission was denied on the object 'spConsumeMagicLinkInvite'"));
|
|
203
|
+
const result = await service.RedeemInvite(rawToken);
|
|
204
|
+
|
|
205
|
+
expect(result).toEqual(expect.objectContaining({ success: false, errorCode: 'server_error' }));
|
|
206
|
+
expect(provisionUser).not.toHaveBeenCalled();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('RedeemInvite still reports consumed when the race is lost', async () => {
|
|
210
|
+
provider.ExecuteSQL.mockResolvedValue([]);
|
|
211
|
+
const result = await service.RedeemInvite(rawToken);
|
|
212
|
+
|
|
213
|
+
expect(result).toEqual(expect.objectContaining({ success: false, errorCode: 'consumed' }));
|
|
214
|
+
expect(provisionUser).not.toHaveBeenCalled();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('RedeemInvite proceeds to provisioning when the consume wins', async () => {
|
|
218
|
+
provider.ExecuteSQL.mockResolvedValue([{ ID: INVITE_ID }]);
|
|
219
|
+
const result = await service.RedeemInvite(rawToken);
|
|
220
|
+
|
|
221
|
+
expect(provisionUser).toHaveBeenCalledTimes(1);
|
|
222
|
+
expect(result).toEqual(expect.objectContaining({ success: false, errorCode: 'provisioning_failed' }));
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostgreSQL pools read the same databaseSettings values as SQL Server, with the same defaults.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from 'vitest';
|
|
5
|
+
import {
|
|
6
|
+
BuildPostgreSQLConnectionConfig,
|
|
7
|
+
CODEGEN_STATEMENT_TIMEOUT_MS,
|
|
8
|
+
ResolvePostgreSQLEndpoint,
|
|
9
|
+
ResolvePostgreSQLReadOnlyCredentials,
|
|
10
|
+
ToPGPoolConfig
|
|
11
|
+
} from '../postgresqlPoolSettings.js';
|
|
12
|
+
import type { DatabaseSettingsInfo } from '../config.js';
|
|
13
|
+
import { DescribeReadOnlyLoginOverreach } from '../postgresqlPoolSettings.js';
|
|
14
|
+
import type { ReadOnlyLoginCheckClient, ReadOnlyLoginGrants } from '../postgresqlPoolSettings.js';
|
|
15
|
+
|
|
16
|
+
const defaults: DatabaseSettingsInfo = {
|
|
17
|
+
connectionTimeout: 45000,
|
|
18
|
+
requestTimeout: 30000,
|
|
19
|
+
metadataCacheRefreshInterval: 180000,
|
|
20
|
+
connectionPool: { max: 50, min: 5, idleTimeoutMillis: 30000, acquireTimeoutMillis: 30000 }
|
|
21
|
+
};
|
|
22
|
+
const endpoint = { Host: 'db', Port: 5432, Database: 'mj', User: 'api', Password: 'secret' };
|
|
23
|
+
|
|
24
|
+
describe('BuildPostgreSQLConnectionConfig', () => {
|
|
25
|
+
it('gives API pools requestTimeout as the statement timeout and the pool settings SQL Server uses', () => {
|
|
26
|
+
const config = BuildPostgreSQLConnectionConfig(endpoint, defaults, 'api');
|
|
27
|
+
expect(config).toMatchObject({
|
|
28
|
+
...endpoint,
|
|
29
|
+
MaxConnections: 50,
|
|
30
|
+
MinConnections: 5,
|
|
31
|
+
IdleTimeoutMillis: 30000,
|
|
32
|
+
ConnectionTimeoutMillis: 30000,
|
|
33
|
+
StatementTimeoutMs: 30000,
|
|
34
|
+
IdleInTransactionSessionTimeoutMs: 60000
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('follows a configured requestTimeout and pool', () => {
|
|
39
|
+
const config = BuildPostgreSQLConnectionConfig(endpoint, {
|
|
40
|
+
...defaults,
|
|
41
|
+
requestTimeout: 5000,
|
|
42
|
+
connectionPool: { max: 12, min: 1, idleTimeoutMillis: 7000, acquireTimeoutMillis: 9000 }
|
|
43
|
+
}, 'api');
|
|
44
|
+
expect(config).toMatchObject({ MaxConnections: 12, MinConnections: 1, IdleTimeoutMillis: 7000, ConnectionTimeoutMillis: 9000, StatementTimeoutMs: 5000, IdleInTransactionSessionTimeoutMs: 10000 });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('sets no timeouts when requestTimeout is 0', () => {
|
|
48
|
+
const config = BuildPostgreSQLConnectionConfig(endpoint, { ...defaults, requestTimeout: 0 }, 'api');
|
|
49
|
+
expect(config.StatementTimeoutMs).toBe(0);
|
|
50
|
+
expect(config.IdleInTransactionSessionTimeoutMs).toBe(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('keeps the read-only pool small but gives it the API timeouts', () => {
|
|
54
|
+
const config = BuildPostgreSQLConnectionConfig(endpoint, defaults, 'read-only');
|
|
55
|
+
expect(config).toMatchObject({ MaxConnections: 10, MinConnections: 0, StatementTimeoutMs: 30000, IdleInTransactionSessionTimeoutMs: 60000 });
|
|
56
|
+
expect(BuildPostgreSQLConnectionConfig(endpoint, { ...defaults, connectionPool: { max: 4, min: 1, idleTimeoutMillis: 1, acquireTimeoutMillis: 1 } }, 'read-only').MaxConnections).toBe(4);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('gives the CodeGen pool the long timeout the SQL Server CodeGen pool has, and no idle-in-transaction limit', () => {
|
|
60
|
+
const config = BuildPostgreSQLConnectionConfig(endpoint, defaults, 'codegen');
|
|
61
|
+
expect(config.StatementTimeoutMs).toBe(CODEGEN_STATEMENT_TIMEOUT_MS);
|
|
62
|
+
expect(CODEGEN_STATEMENT_TIMEOUT_MS).toBe(600000);
|
|
63
|
+
expect(config.IdleInTransactionSessionTimeoutMs).toBe(0);
|
|
64
|
+
expect(config.MaxConnections).toBe(10);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('ToPGPoolConfig', () => {
|
|
69
|
+
it('maps the connection config onto pg.Pool option names', () => {
|
|
70
|
+
const pool = ToPGPoolConfig(BuildPostgreSQLConnectionConfig(endpoint, defaults, 'api'));
|
|
71
|
+
expect(pool).toEqual({
|
|
72
|
+
host: 'db', port: 5432, database: 'mj', user: 'api', password: 'secret',
|
|
73
|
+
max: 50, min: 5, idleTimeoutMillis: 30000, connectionTimeoutMillis: 30000,
|
|
74
|
+
statement_timeout: 30000, idle_in_transaction_session_timeout: 60000
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('leaves the timeouts out when they are 0', () => {
|
|
79
|
+
const pool = ToPGPoolConfig(BuildPostgreSQLConnectionConfig(endpoint, { ...defaults, requestTimeout: 0 }, 'api'));
|
|
80
|
+
expect(pool).not.toHaveProperty('statement_timeout');
|
|
81
|
+
expect(pool).not.toHaveProperty('idle_in_transaction_session_timeout');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('ResolvePostgreSQLEndpoint', () => {
|
|
86
|
+
it('prefers PG_* variables over DB_* ones', () => {
|
|
87
|
+
expect(ResolvePostgreSQLEndpoint({ PG_HOST: 'pg', DB_HOST: 'db', DB_PORT: '6000', DB_DATABASE: 'd', DB_USERNAME: 'u', DB_PASSWORD: 'p' }))
|
|
88
|
+
.toEqual({ Host: 'pg', Port: 6000, Database: 'd', User: 'u', Password: 'p' });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('falls back to localhost:5432 and the postgres user', () => {
|
|
92
|
+
expect(ResolvePostgreSQLEndpoint({})).toEqual({ Host: 'localhost', Port: 5432, Database: '', User: 'postgres', Password: '' });
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('ResolvePostgreSQLReadOnlyCredentials', () => {
|
|
97
|
+
it('uses the dbReadOnlyUsername / dbReadOnlyPassword settings SQL Server uses', () => {
|
|
98
|
+
expect(ResolvePostgreSQLReadOnlyCredentials({ dbReadOnlyUsername: 'ro', dbReadOnlyPassword: 'pw' }, {}))
|
|
99
|
+
.toEqual({ User: 'ro', Password: 'pw' });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('falls back to PG_READ_ONLY_* when the settings are absent', () => {
|
|
103
|
+
expect(ResolvePostgreSQLReadOnlyCredentials({}, { PG_READ_ONLY_USERNAME: 'pgro', PG_READ_ONLY_PASSWORD: 'x' }))
|
|
104
|
+
.toEqual({ User: 'pgro', Password: 'x' });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('returns null when no complete pair is configured', () => {
|
|
108
|
+
expect(ResolvePostgreSQLReadOnlyCredentials({ dbReadOnlyUsername: 'ro' }, { PG_READ_ONLY_USERNAME: 'pgro' })).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('DescribeReadOnlyLoginOverreach', () => {
|
|
113
|
+
const clientAnswering = (row: ReadOnlyLoginGrants): ReadOnlyLoginCheckClient => ({
|
|
114
|
+
query: async () => ({ rows: [row] }),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('says nothing for a login that reads only views', async () => {
|
|
118
|
+
expect(await DescribeReadOnlyLoginOverreach(clientAnswering({ ReadsServerFiles: false, ReadableBaseTables: 0 }), '__mj')).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('warns when the login can read base tables', async () => {
|
|
122
|
+
const warnings = await DescribeReadOnlyLoginOverreach(clientAnswering({ ReadsServerFiles: false, ReadableBaseTables: 412 }), '__mj');
|
|
123
|
+
expect(warnings).toHaveLength(1);
|
|
124
|
+
expect(warnings[0]).toMatch(/can read 412 base table\(s\) in schema "__mj"/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('warns when the login can read server files', async () => {
|
|
128
|
+
const warnings = await DescribeReadOnlyLoginOverreach(clientAnswering({ ReadsServerFiles: true, ReadableBaseTables: 0 }), '__mj');
|
|
129
|
+
expect(warnings[0]).toMatch(/pg_read_server_files/);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generated resolvers emit SQL Server bracket-quoted identifiers; on PostgreSQL they become
|
|
3
|
+
* double-quoted. Only identifiers change: brackets inside a string literal are data.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import { TranslateBracketsToPG } from '../postgresqlCompat.js';
|
|
7
|
+
|
|
8
|
+
describe('TranslateBracketsToPG', () => {
|
|
9
|
+
it('turns bracket-quoted identifiers into double-quoted ones', () => {
|
|
10
|
+
expect(TranslateBracketsToPG('SELECT [ID], [Name] FROM [__mj].[vwUsers]')).toBe('SELECT "ID", "Name" FROM "__mj"."vwUsers"');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('leaves brackets inside a string literal alone', () => {
|
|
14
|
+
expect(TranslateBracketsToPG("SELECT [ID] FROM [t] WHERE [Note] = '[draft] copy'")).toBe(`SELECT "ID" FROM "t" WHERE "Note" = '[draft] copy'`);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('unescapes ]] and escapes a double quote in the name', () => {
|
|
18
|
+
expect(TranslateBracketsToPG('SELECT [a]]b], [say "hi"] FROM t')).toBe('SELECT "a]b", "say ""hi""" FROM t');
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -35,7 +35,7 @@ import { CommunicationEngine } from '@memberjunction/communication-engine';
|
|
|
35
35
|
import { Message } from '@memberjunction/communication-types';
|
|
36
36
|
import { configInfo, type MagicLinkConfig } from '../../config.js';
|
|
37
37
|
import { MagicLinkKeyManager } from './MagicLinkKeys.js';
|
|
38
|
-
import { generateRawToken, generateSessionId, hashToken, evaluateInvite, buildSessionClaims,
|
|
38
|
+
import { generateRawToken, generateSessionId, hashToken, evaluateInvite, buildSessionClaims, buildConsumeInvitePostgresSQL, canIssueInvites, isRoleGrantable, MAGIC_LINK_TOKEN_PREFIX } from './magicLinkCore.js';
|
|
39
39
|
import type {
|
|
40
40
|
CreateMagicLinkInviteParams,
|
|
41
41
|
CreateMagicLinkInviteResult,
|
|
@@ -48,6 +48,21 @@ const REDEMPTION_ENTITY = 'MJ: Magic Link Redemptions';
|
|
|
48
48
|
/** Shared Anonymous principal — seeded by the Phase-4 migration; must match that UUID. */
|
|
49
49
|
const ANONYMOUS_USER_ID = '273910DF-28F1-45C1-A8F8-6E9AD8E5F008';
|
|
50
50
|
|
|
51
|
+
/** Outcome of the atomic single-use consume — see {@link MagicLinkService.consumeInvite}. */
|
|
52
|
+
type ConsumeOutcome = 'won' | 'lost' | 'failed';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The SQL Server guarded single-use procedure (migration V202609291627__v6.2.x__MagicLink_Consume_Invite_Sproc).
|
|
56
|
+
* A procedure, not raw DML, because on SQL Server MJ grants its runtime roles EXECUTE on procedures
|
|
57
|
+
* and SELECT on views — never DML on base tables — so on any host whose MJAPI login is not db_owner
|
|
58
|
+
* a raw UPDATE is refused (#4753). Ownership chaining lets the procedure update the table on the
|
|
59
|
+
* caller's behalf. PostgreSQL has no counterpart function; see {@link buildConsumeInvitePostgresSQL}.
|
|
60
|
+
*/
|
|
61
|
+
const CONSUME_INVITE_PROC = 'spConsumeMagicLinkInvite';
|
|
62
|
+
|
|
63
|
+
/** MagicLinkInvite base table (core schema) — the PostgreSQL consume updates it directly. */
|
|
64
|
+
const INVITE_TABLE = 'MagicLinkInvite';
|
|
65
|
+
|
|
51
66
|
/** Outcome of provisioning a redeeming user. */
|
|
52
67
|
interface ProvisionResult {
|
|
53
68
|
success: boolean;
|
|
@@ -173,9 +188,9 @@ export class MagicLinkService {
|
|
|
173
188
|
* session JWT. Runs in the public (pre-auth) path, so it uses the global
|
|
174
189
|
* provider and a configured provisioning context user.
|
|
175
190
|
*
|
|
176
|
-
* Single-use is enforced by
|
|
177
|
-
* BEFORE the token is minted — two concurrent
|
|
178
|
-
* link race on the same row and exactly one wins. The consume is the gate:
|
|
191
|
+
* Single-use is enforced by the guarded UPDATE inside the `spConsumeMagicLinkInvite`
|
|
192
|
+
* procedure ({@link consumeInvite}) BEFORE the token is minted — two concurrent
|
|
193
|
+
* redemptions of a single-use link race on the same row and exactly one wins. The consume is the gate:
|
|
179
194
|
* if a later step fails, the use is already burned (fail-closed). That is the
|
|
180
195
|
* correct trade-off for a single-use credential — burning a token on a server
|
|
181
196
|
* error is recoverable (re-issue); allowing replay is not.
|
|
@@ -252,10 +267,14 @@ export class MagicLinkService {
|
|
|
252
267
|
return done({ success: false, errorCode: 'server_error', error: 'Invite role not found.' });
|
|
253
268
|
}
|
|
254
269
|
|
|
255
|
-
// Atomic consume BEFORE minting.
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
270
|
+
// Atomic consume BEFORE minting. A statement that never ran (permissions,
|
|
271
|
+
// missing procedure, connection) is a server fault, not a lost race — report
|
|
272
|
+
// it as such so operators aren't sent hunting a double-redeem (#4753).
|
|
273
|
+
const consume = await this.consumeInvite(invite, provider, contextUser);
|
|
274
|
+
if (consume === 'failed') {
|
|
275
|
+
return done({ success: false, errorCode: 'server_error', error: 'Could not redeem the invite due to a server error.' });
|
|
276
|
+
}
|
|
277
|
+
if (consume === 'lost') {
|
|
259
278
|
// Lost the race or the invite expired between the pre-check and the
|
|
260
279
|
// consume. The in-memory copy still looks eligible if only the DB-side
|
|
261
280
|
// use count changed, so default that case to 'consumed'.
|
|
@@ -639,39 +658,39 @@ export class MagicLinkService {
|
|
|
639
658
|
}
|
|
640
659
|
|
|
641
660
|
/**
|
|
642
|
-
* Atomically consumes one use of the invite
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
* (won the race); concurrent redemptions of a single-use link see 0 rows.
|
|
661
|
+
* Atomically consumes one use of the invite with a single guarded UPDATE that re-checks every
|
|
662
|
+
* eligibility condition (Active, not exhausted, not expired) at the DB level, so the increment and
|
|
663
|
+
* the guard are one atomic operation. Concurrent redemptions of a single-use link race on the row
|
|
664
|
+
* and exactly one gets the row back.
|
|
647
665
|
*
|
|
648
|
-
*
|
|
649
|
-
*
|
|
650
|
-
*
|
|
651
|
-
*
|
|
652
|
-
*
|
|
653
|
-
*
|
|
666
|
+
* The statement differs per platform because the grants do:
|
|
667
|
+
* - **SQL Server** calls {@link CONSUME_INVITE_PROC} (`EXEC … @ID=@p0`). Runtime roles have
|
|
668
|
+
* EXECUTE on procedures but no DML on base tables (#4753).
|
|
669
|
+
* - **PostgreSQL** runs {@link buildConsumeInvitePostgresSQL}'s `UPDATE … RETURNING` directly.
|
|
670
|
+
* Runtime roles hold table DML there, and no PG counterpart of the procedure exists.
|
|
671
|
+
* The ID is always a bound parameter.
|
|
654
672
|
*
|
|
655
|
-
*
|
|
673
|
+
* Returns `'won'` (this call consumed a use), `'lost'` (the guard matched nothing: consumed,
|
|
674
|
+
* revoked or expired concurrently) or `'failed'` (the statement never ran — permissions, a missing
|
|
675
|
+
* procedure, a connection error). `'failed'` is kept distinct from `'lost'` on purpose: collapsing
|
|
676
|
+
* them is what made #4753's permission error surface to users as "already redeemed".
|
|
656
677
|
*/
|
|
657
|
-
private async consumeInvite(invite: MJMagicLinkInviteEntity, provider: DatabaseProviderBase, contextUser: UserInfo): Promise<
|
|
678
|
+
private async consumeInvite(invite: MJMagicLinkInviteEntity, provider: DatabaseProviderBase, contextUser: UserInfo): Promise<ConsumeOutcome> {
|
|
679
|
+
const isPg = provider.PlatformKey === 'postgresql';
|
|
680
|
+
const statement = isPg ? `PostgreSQL guarded UPDATE on ${INVITE_TABLE}` : CONSUME_INVITE_PROC;
|
|
658
681
|
try {
|
|
659
|
-
const
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
// OUTPUT/RETURNING yields one row iff the WHERE matched — the atomic single-use gate.
|
|
669
|
-
const sql = buildConsumeInviteSQL(table, isPg ? 'postgresql' : 'sqlserver');
|
|
670
|
-
const rows = await provider.ExecuteSQL<{ ID: string }>(sql, [invite.ID], { isMutation: true }, contextUser);
|
|
671
|
-
return Array.isArray(rows) && rows.length === 1;
|
|
682
|
+
const sql = isPg
|
|
683
|
+
? buildConsumeInvitePostgresSQL(`${provider.MJCoreSchemaName}.${INVITE_TABLE}`)
|
|
684
|
+
: provider.Dialect.ProcedureCallSyntax(provider.MJCoreSchemaName, CONSUME_INVITE_PROC, [`@ID=${provider.BuildParameterPlaceholder(0)}`]);
|
|
685
|
+
const rows = await provider.ExecuteSQL<{ ID: string }>(sql, [invite.ID], { isMutation: true, description: `MagicLink ${statement}` }, contextUser);
|
|
686
|
+
if (!Array.isArray(rows)) {
|
|
687
|
+
LogError(`[MagicLink] ${statement} returned no result set for invite ${invite.ID}; treating the consume as failed.`);
|
|
688
|
+
return 'failed';
|
|
689
|
+
}
|
|
690
|
+
return rows.length === 1 ? 'won' : 'lost';
|
|
672
691
|
} catch (e) {
|
|
673
|
-
LogError(`[MagicLink]
|
|
674
|
-
return
|
|
692
|
+
LogError(`[MagicLink] ${statement} FAILED for invite ${invite.ID} — the statement never ran, so this is NOT a lost race: ${e instanceof Error ? e.message : String(e)}`);
|
|
693
|
+
return 'failed';
|
|
675
694
|
}
|
|
676
695
|
}
|
|
677
696
|
|