@open-mercato/channel-gmail 0.10.1-develop.7776.1.3032ea0741 → 0.10.1-develop.7781.1.079f42c863
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/modules/channel_gmail/lib/credentials.js +1 -1
- package/dist/modules/channel_gmail/lib/credentials.js.map +2 -2
- package/package.json +5 -5
- package/src/modules/channel_gmail/lib/__tests__/adapter.test.ts +33 -0
- package/src/modules/channel_gmail/lib/__tests__/credentials.test.ts +21 -0
- package/src/modules/channel_gmail/lib/credentials.ts +1 -1
|
@@ -21,7 +21,7 @@ const gmailUserCredentialsSchema = z.object({
|
|
|
21
21
|
scopes: z.array(z.string()).optional(),
|
|
22
22
|
/** Email address from the linked Google account. */
|
|
23
23
|
email: z.string().email().optional()
|
|
24
|
-
}).
|
|
24
|
+
}).strip();
|
|
25
25
|
const gmailChannelStateSchema = z.object({
|
|
26
26
|
historyId: z.union([z.string(), z.number()]).optional(),
|
|
27
27
|
lastSyncedAt: z.string().datetime().optional(),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/channel_gmail/lib/credentials.ts"],
|
|
4
|
-
"sourcesContent": ["import { z } from 'zod'\n\n/**\n * Tenant-level OAuth client configuration. Stored on `IntegrationCredentials`\n * for the `gmail` provider when the tenant has set up their own Google Cloud\n * project. Per-user OAuth tokens layer on top via `userCredentialsSchema`.\n */\nexport const gmailClientCredentialsSchema = z\n .object({\n clientId: z.string().min(1, 'OAuth Client ID required'),\n clientSecret: z.string().min(1, 'OAuth Client Secret required'),\n /** Comma-separated scopes; blank uses defaults. */\n scopes: z.string().optional(),\n })\n .strict()\n\nexport type GmailClientCredentials = z.infer<typeof gmailClientCredentialsSchema>\n\n/**\n * Per-user OAuth tokens stored on `CommunicationChannel.credentials` (encrypted).\n * The hub injects the tenant client_id / client_secret at exchange/refresh time;\n * the per-channel blob only persists the user-bound tokens.\n */\nexport const gmailUserCredentialsSchema = z\n .object({\n accessToken: z.string({ error: 'Access token required' }).min(1, 'Access token required'),\n /**\n * Gmail issues a refresh token only on the first consent. If the user\n * re-authorises, Google does NOT send a new refresh token unless we pass\n * `prompt=consent` and `access_type=offline`; we always do. We still mark\n * the field optional so legacy migrations from accounts that never received\n * one don't fail the schema \u2014 the runtime treats absence as \"requires_reauth\".\n */\n refreshToken: z.string().optional(),\n /** ISO timestamp of access-token expiry. */\n expiresAt: z.string().datetime().optional(),\n /** Scopes that were actually granted (we may have requested a subset). */\n scopes: z.array(z.string()).optional(),\n /** Email address from the linked Google account. */\n email: z.string().email().optional(),\n })\n .
|
|
5
|
-
"mappings": "AAAA,SAAS,SAAS;AAOX,MAAM,+BAA+B,EACzC,OAAO;AAAA,EACN,UAAU,EAAE,OAAO,EAAE,IAAI,GAAG,0BAA0B;AAAA,EACtD,cAAc,EAAE,OAAO,EAAE,IAAI,GAAG,8BAA8B;AAAA;AAAA,EAE9D,QAAQ,EAAE,OAAO,EAAE,SAAS;AAC9B,CAAC,EACA,OAAO;AASH,MAAM,6BAA6B,EACvC,OAAO;AAAA,EACN,aAAa,EAAE,OAAO,EAAE,OAAO,wBAAwB,CAAC,EAAE,IAAI,GAAG,uBAAuB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQxF,cAAc,EAAE,OAAO,EAAE,SAAS;AAAA;AAAA,EAElC,WAAW,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA;AAAA,EAE1C,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,SAAS;AAAA;AAAA,EAErC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS;AACrC,CAAC,EACA,
|
|
4
|
+
"sourcesContent": ["import { z } from 'zod'\n\n/**\n * Tenant-level OAuth client configuration. Stored on `IntegrationCredentials`\n * for the `gmail` provider when the tenant has set up their own Google Cloud\n * project. Per-user OAuth tokens layer on top via `userCredentialsSchema`.\n */\nexport const gmailClientCredentialsSchema = z\n .object({\n clientId: z.string().min(1, 'OAuth Client ID required'),\n clientSecret: z.string().min(1, 'OAuth Client Secret required'),\n /** Comma-separated scopes; blank uses defaults. */\n scopes: z.string().optional(),\n })\n .strict()\n\nexport type GmailClientCredentials = z.infer<typeof gmailClientCredentialsSchema>\n\n/**\n * Per-user OAuth tokens stored on `CommunicationChannel.credentials` (encrypted).\n * The hub injects the tenant client_id / client_secret at exchange/refresh time;\n * the per-channel blob only persists the user-bound tokens.\n */\nexport const gmailUserCredentialsSchema = z\n .object({\n accessToken: z.string({ error: 'Access token required' }).min(1, 'Access token required'),\n /**\n * Gmail issues a refresh token only on the first consent. If the user\n * re-authorises, Google does NOT send a new refresh token unless we pass\n * `prompt=consent` and `access_type=offline`; we always do. We still mark\n * the field optional so legacy migrations from accounts that never received\n * one don't fail the schema \u2014 the runtime treats absence as \"requires_reauth\".\n */\n refreshToken: z.string().optional(),\n /** ISO timestamp of access-token expiry. */\n expiresAt: z.string().datetime().optional(),\n /** Scopes that were actually granted (we may have requested a subset). */\n scopes: z.array(z.string()).optional(),\n /** Email address from the linked Google account. */\n email: z.string().email().optional(),\n })\n .strip()\n\nexport type GmailUserCredentials = z.infer<typeof gmailUserCredentialsSchema>\n\n/**\n * Per-channel sync state stored on `CommunicationChannel.channelState`.\n *\n * historyId \u2014 Gmail's per-mailbox monotonic cursor used by `history.list`\n * to fetch only changes since the previous poll. If history has\n * expired (Gmail keeps roughly 7 days), we fall back to a full\n * list using `gmail.users.messages.list`.\n *\n * pendingHistoryPageToken \u2014 mid-drain resumption state when a single tick\n * can't ingest every page of `history.list` (e.g. a high-volume\n * mailbox returned more than our per-tick budget). The terminal\n * `historyId` is NOT advanced until the pages drain. The next tick\n * resumes via the stored `historyId` + this `pageToken`.\n *\n * pendingMessagesPageToken / pendingMessagesHistoryIdSnapshot \u2014 same\n * contract for the 404-fallback path (`messages.list`).\n *\n * See https://developers.google.com/gmail/api/guides/sync for the contract.\n */\nexport const gmailChannelStateSchema = z\n .object({\n historyId: z.union([z.string(), z.number()]).optional(),\n lastSyncedAt: z.string().datetime().optional(),\n pendingHistoryPageToken: z.string().optional(),\n pendingMessagesPageToken: z.string().optional(),\n pendingMessagesHistoryIdSnapshot: z.string().optional(),\n })\n .partial()\n .passthrough()\n\nexport type GmailChannelState = z.infer<typeof gmailChannelStateSchema>\n\nexport const GMAIL_DEFAULT_SCOPES = [\n 'https://www.googleapis.com/auth/gmail.modify',\n 'https://www.googleapis.com/auth/userinfo.email',\n 'https://www.googleapis.com/auth/userinfo.profile',\n]\n\nexport function parseScopes(value: string | undefined): string[] {\n if (!value || !value.trim()) return [...GMAIL_DEFAULT_SCOPES]\n return value\n .split(/[\\s,]+/)\n .map((s) => s.trim())\n .filter((s) => s.length > 0)\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,SAAS;AAOX,MAAM,+BAA+B,EACzC,OAAO;AAAA,EACN,UAAU,EAAE,OAAO,EAAE,IAAI,GAAG,0BAA0B;AAAA,EACtD,cAAc,EAAE,OAAO,EAAE,IAAI,GAAG,8BAA8B;AAAA;AAAA,EAE9D,QAAQ,EAAE,OAAO,EAAE,SAAS;AAC9B,CAAC,EACA,OAAO;AASH,MAAM,6BAA6B,EACvC,OAAO;AAAA,EACN,aAAa,EAAE,OAAO,EAAE,OAAO,wBAAwB,CAAC,EAAE,IAAI,GAAG,uBAAuB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQxF,cAAc,EAAE,OAAO,EAAE,SAAS;AAAA;AAAA,EAElC,WAAW,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA;AAAA,EAE1C,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,SAAS;AAAA;AAAA,EAErC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS;AACrC,CAAC,EACA,MAAM;AAuBF,MAAM,0BAA0B,EACpC,OAAO;AAAA,EACN,WAAW,EAAE,MAAM,CAAC,EAAE,OAAO,GAAG,EAAE,OAAO,CAAC,CAAC,EAAE,SAAS;AAAA,EACtD,cAAc,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EAC7C,yBAAyB,EAAE,OAAO,EAAE,SAAS;AAAA,EAC7C,0BAA0B,EAAE,OAAO,EAAE,SAAS;AAAA,EAC9C,kCAAkC,EAAE,OAAO,EAAE,SAAS;AACxD,CAAC,EACA,QAAQ,EACR,YAAY;AAIR,MAAM,uBAAuB;AAAA,EAClC;AAAA,EACA;AAAA,EACA;AACF;AAEO,SAAS,YAAY,OAAqC;AAC/D,MAAI,CAAC,SAAS,CAAC,MAAM,KAAK,EAAG,QAAO,CAAC,GAAG,oBAAoB;AAC5D,SAAO,MACJ,MAAM,QAAQ,EACd,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,EACnB,OAAO,CAAC,MAAM,EAAE,SAAS,CAAC;AAC/B;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/channel-gmail",
|
|
3
|
-
"version": "0.10.1-develop.
|
|
3
|
+
"version": "0.10.1-develop.7781.1.079f42c863",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -62,19 +62,19 @@
|
|
|
62
62
|
}
|
|
63
63
|
},
|
|
64
64
|
"dependencies": {
|
|
65
|
-
"@open-mercato/core": "0.10.1-develop.
|
|
66
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
65
|
+
"@open-mercato/core": "0.10.1-develop.7781.1.079f42c863",
|
|
66
|
+
"@open-mercato/ui": "0.10.1-develop.7781.1.079f42c863",
|
|
67
67
|
"@types/mailparser": "^3.4.5",
|
|
68
68
|
"mailparser": "^3.9.17"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
71
|
"@mikro-orm/postgresql": "^7.0.14",
|
|
72
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
72
|
+
"@open-mercato/shared": "0.10.1-develop.7781.1.079f42c863",
|
|
73
73
|
"react": "^19.0.0",
|
|
74
74
|
"react-dom": "^19.0.0"
|
|
75
75
|
},
|
|
76
76
|
"devDependencies": {
|
|
77
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
77
|
+
"@open-mercato/shared": "0.10.1-develop.7781.1.079f42c863",
|
|
78
78
|
"@types/jest": "^30.0.0",
|
|
79
79
|
"@types/react": "^19.2.18",
|
|
80
80
|
"@types/react-dom": "^19.2.5",
|
|
@@ -436,6 +436,39 @@ describe('GmailChannelAdapter OAuth flow', () => {
|
|
|
436
436
|
expect(refreshToken).not.toHaveBeenCalled()
|
|
437
437
|
})
|
|
438
438
|
|
|
439
|
+
it('refreshes a legacy blob using only the trusted OAuth client and known token fields', async () => {
|
|
440
|
+
const refreshToken = jest.fn(async () => ({
|
|
441
|
+
access_token: 'new-access',
|
|
442
|
+
expires_in: 1800,
|
|
443
|
+
token_type: 'Bearer',
|
|
444
|
+
}))
|
|
445
|
+
setGoogleOAuthClient(stubOAuth({ refreshToken }))
|
|
446
|
+
|
|
447
|
+
const result = await getGmailChannelAdapter().refreshCredentials!({
|
|
448
|
+
channelId: 'channel-1',
|
|
449
|
+
credentials: {
|
|
450
|
+
...userCredentials,
|
|
451
|
+
_client: { clientId: 'untrusted-client', clientSecret: 'untrusted-secret' },
|
|
452
|
+
extra: 'legacy-metadata',
|
|
453
|
+
},
|
|
454
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
455
|
+
oauthClient: { clientId: 'trusted-client', clientSecret: 'trusted-secret' },
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
expect(refreshToken).toHaveBeenCalledWith({
|
|
459
|
+
clientId: 'trusted-client',
|
|
460
|
+
clientSecret: 'trusted-secret',
|
|
461
|
+
refreshToken: userCredentials.refreshToken,
|
|
462
|
+
})
|
|
463
|
+
expect(result.credentials).toEqual({
|
|
464
|
+
accessToken: 'new-access',
|
|
465
|
+
refreshToken: userCredentials.refreshToken,
|
|
466
|
+
expiresAt: expect.any(String),
|
|
467
|
+
scopes: undefined,
|
|
468
|
+
email: userCredentials.email,
|
|
469
|
+
})
|
|
470
|
+
})
|
|
471
|
+
|
|
439
472
|
it('throws a clear error when oauthClient is absent', async () => {
|
|
440
473
|
setGoogleOAuthClient(stubOAuth({}))
|
|
441
474
|
await expect(
|
|
@@ -41,6 +41,27 @@ describe('gmailUserCredentialsSchema', () => {
|
|
|
41
41
|
expect(() => gmailUserCredentialsSchema.parse({ refreshToken: 'r' })).toThrow(/Access token/i)
|
|
42
42
|
})
|
|
43
43
|
|
|
44
|
+
it.each([
|
|
45
|
+
{ accessToken: 'a' },
|
|
46
|
+
{
|
|
47
|
+
accessToken: 'a',
|
|
48
|
+
refreshToken: 'r',
|
|
49
|
+
expiresAt: '2026-05-26T10:00:00.000Z',
|
|
50
|
+
scopes: ['https://www.googleapis.com/auth/gmail.modify'],
|
|
51
|
+
email: 'alice@gmail.com',
|
|
52
|
+
},
|
|
53
|
+
])('discards unknown stored fields while retaining known credentials: %j', (credentials) => {
|
|
54
|
+
const extraFields = {
|
|
55
|
+
_client: { clientId: 'untrusted-client', clientSecret: 'untrusted-secret' },
|
|
56
|
+
clientSecret: 'unexpected-secret',
|
|
57
|
+
metadata: { arbitrary: true },
|
|
58
|
+
}
|
|
59
|
+
const storedCredentials = { ...credentials, ...extraFields }
|
|
60
|
+
|
|
61
|
+
expect(gmailUserCredentialsSchema.parse(storedCredentials)).toEqual(credentials)
|
|
62
|
+
expect(storedCredentials).toEqual({ ...credentials, ...extraFields })
|
|
63
|
+
})
|
|
64
|
+
|
|
44
65
|
it('rejects invalid expiresAt format', () => {
|
|
45
66
|
expect(() => gmailUserCredentialsSchema.parse({ accessToken: 'a', expiresAt: 'soon' })).toThrow()
|
|
46
67
|
})
|
|
@@ -39,7 +39,7 @@ export const gmailUserCredentialsSchema = z
|
|
|
39
39
|
/** Email address from the linked Google account. */
|
|
40
40
|
email: z.string().email().optional(),
|
|
41
41
|
})
|
|
42
|
-
.
|
|
42
|
+
.strip()
|
|
43
43
|
|
|
44
44
|
export type GmailUserCredentials = z.infer<typeof gmailUserCredentialsSchema>
|
|
45
45
|
|