@tuturuuu/utils 0.33.3 → 0.34.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/CHANGELOG.md +34 -0
- package/package.json +4 -3
- package/src/__tests__/api-proxy-bearer-trust.test.ts +3 -0
- package/src/__tests__/api-proxy-guard.test.ts +6 -13
- package/src/__tests__/api-proxy-offline-response.test.ts +48 -0
- package/src/__tests__/api-proxy-test-request.ts +19 -0
- package/src/__tests__/daily-operation-download-isolation.test.ts +137 -0
- package/src/__tests__/offline-download-guard.test.ts +340 -0
- package/src/__tests__/username-policy.test.ts +70 -0
- package/src/account-benefits-server.ts +43 -0
- package/src/account-benefits.ts +45 -0
- package/src/api-proxy-guard.ts +8 -42
- package/src/api-proxy-read-policy.ts +43 -0
- package/src/constants.ts +3 -0
- package/src/google-calendar-import-fence.test.ts +182 -0
- package/src/google-calendar-import-fence.ts +166 -0
- package/src/offline-download-guard.ts +160 -0
- package/src/platform-release.test.ts +1 -1
- package/src/platform-release.ts +1 -1
- package/src/playground-schema.test.ts +119 -0
- package/src/playground-schema.ts +173 -0
- package/src/playground-templates.ts +65 -0
- package/src/release-note-policy-core.cjs +28 -0
- package/src/release-note-policy-core.d.cts +5 -0
- package/src/release-note-policy.json +9 -0
- package/src/release-note-policy.ts +4 -0
- package/src/username-policy.json +87 -0
- package/src/username-policy.ts +18 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { isReservedUsername, isValidNewUsername } from '../username-policy';
|
|
6
|
+
import reserved from '../username-policy.json';
|
|
7
|
+
|
|
8
|
+
describe('Canonical username policy', () => {
|
|
9
|
+
it.each([
|
|
10
|
+
'four',
|
|
11
|
+
'abc',
|
|
12
|
+
'_invalid',
|
|
13
|
+
'Uppercase',
|
|
14
|
+
'has-hyphen',
|
|
15
|
+
'a'.repeat(33),
|
|
16
|
+
])('rejects invalid new usernames %s', (value) => {
|
|
17
|
+
expect(isValidNewUsername(value)).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
it.each([
|
|
20
|
+
'apple',
|
|
21
|
+
'google',
|
|
22
|
+
'microsoft',
|
|
23
|
+
'support',
|
|
24
|
+
'admin',
|
|
25
|
+
'admin123',
|
|
26
|
+
'supportteam',
|
|
27
|
+
'official_admin',
|
|
28
|
+
's_u_p_p_o_r_t123',
|
|
29
|
+
'g_o_o_g_l_e',
|
|
30
|
+
'apple123',
|
|
31
|
+
'official_microsoft',
|
|
32
|
+
'google_support',
|
|
33
|
+
])('reserves common and branded identities %s', (value) => {
|
|
34
|
+
expect(isReservedUsername(value)).toBe(true);
|
|
35
|
+
expect(isValidNewUsername(value)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
it.each([
|
|
38
|
+
'quiet_artist',
|
|
39
|
+
'my_world',
|
|
40
|
+
'pineapple',
|
|
41
|
+
'my_supportteam',
|
|
42
|
+
'river123',
|
|
43
|
+
'abcde',
|
|
44
|
+
'a'.repeat(32),
|
|
45
|
+
])('accepts a distinct creative username %s', (value) => {
|
|
46
|
+
expect(isValidNewUsername(value)).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
it('keeps every canonical reserved name enforced in the database migration', () => {
|
|
49
|
+
const sql = readFileSync(
|
|
50
|
+
resolve(
|
|
51
|
+
__dirname,
|
|
52
|
+
'../../../../apps/database/supabase/migrations/20261004004000_public_creator_identity.sql'
|
|
53
|
+
),
|
|
54
|
+
'utf8'
|
|
55
|
+
);
|
|
56
|
+
const seeds = [
|
|
57
|
+
...sql.matchAll(
|
|
58
|
+
/jsonb_array_elements_text\('([^']+)'::jsonb\),'(common|brand)'/g
|
|
59
|
+
),
|
|
60
|
+
];
|
|
61
|
+
expect(seeds).toHaveLength(2);
|
|
62
|
+
for (const [category, values] of [
|
|
63
|
+
['common', reserved.common],
|
|
64
|
+
['brand', reserved.brands],
|
|
65
|
+
] as const) {
|
|
66
|
+
const seed = seeds.find((match) => match[2] === category);
|
|
67
|
+
expect(JSON.parse(seed![1]!)).toEqual(values);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
import { createAdminClient } from '@tuturuuu/supabase/next/server';
|
|
3
|
+
export class AccountServiceError extends Error {
|
|
4
|
+
constructor(
|
|
5
|
+
readonly status: number,
|
|
6
|
+
message = 'Account request failed'
|
|
7
|
+
) {
|
|
8
|
+
super(message);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export interface AccountPrivateTransport {
|
|
12
|
+
rpc<T>(
|
|
13
|
+
name: string,
|
|
14
|
+
args: Record<string, unknown>
|
|
15
|
+
): Promise<{ data: T | null; error: { code?: string } | null }>;
|
|
16
|
+
}
|
|
17
|
+
export async function accountPrivateRpc<T>(
|
|
18
|
+
name: string,
|
|
19
|
+
args: Record<string, unknown>
|
|
20
|
+
): Promise<T> {
|
|
21
|
+
const admin = await createAdminClient({ noCookie: true });
|
|
22
|
+
const client = (
|
|
23
|
+
admin as unknown as { schema(name: string): AccountPrivateTransport }
|
|
24
|
+
).schema('private');
|
|
25
|
+
const result = await client.rpc<T>(name, args);
|
|
26
|
+
if (result.error || result.data === null) {
|
|
27
|
+
const code = result.error?.code;
|
|
28
|
+
throw new AccountServiceError(
|
|
29
|
+
code === '42501'
|
|
30
|
+
? 403
|
|
31
|
+
: (result.data === null && !result.error) || code === 'P0002'
|
|
32
|
+
? 404
|
|
33
|
+
: code === '40001' || code === '23505'
|
|
34
|
+
? 409
|
|
35
|
+
: code === '22023'
|
|
36
|
+
? 400
|
|
37
|
+
: code === '53300'
|
|
38
|
+
? 503
|
|
39
|
+
: 500
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return result.data;
|
|
43
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const AccountBenefitKey = z
|
|
3
|
+
.string()
|
|
4
|
+
.regex(/^(feature|early_access)\.[a-z][a-z0-9_.-]{0,79}$/)
|
|
5
|
+
.or(z.literal('ai_credits'));
|
|
6
|
+
export const AccountBenefitGrant = z
|
|
7
|
+
.object({
|
|
8
|
+
userId: z.guid(),
|
|
9
|
+
key: AccountBenefitKey,
|
|
10
|
+
amount: z.number().int().min(1).max(1_000_000),
|
|
11
|
+
reason: z.string().trim().min(3).max(500),
|
|
12
|
+
expiresAt: z.iso.datetime({ offset: true }).nullable(),
|
|
13
|
+
requestId: z.guid(),
|
|
14
|
+
})
|
|
15
|
+
.strict()
|
|
16
|
+
.superRefine((value, ctx) => {
|
|
17
|
+
if (value.key !== 'ai_credits' && value.amount !== 1)
|
|
18
|
+
ctx.addIssue({
|
|
19
|
+
code: 'custom',
|
|
20
|
+
message: 'Feature grants have amount one',
|
|
21
|
+
});
|
|
22
|
+
if (value.key === 'ai_credits' && value.expiresAt !== null)
|
|
23
|
+
ctx.addIssue({
|
|
24
|
+
code: 'custom',
|
|
25
|
+
message: 'Credits use the billing period expiry',
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
export function hasAccountBenefit(
|
|
29
|
+
grants: {
|
|
30
|
+
benefit_key: string;
|
|
31
|
+
starts_at: string;
|
|
32
|
+
expires_at: string | null;
|
|
33
|
+
revoked_at: string | null;
|
|
34
|
+
}[],
|
|
35
|
+
key: string,
|
|
36
|
+
now = Date.now()
|
|
37
|
+
) {
|
|
38
|
+
return grants.some(
|
|
39
|
+
(grant) =>
|
|
40
|
+
grant.benefit_key === key &&
|
|
41
|
+
!grant.revoked_at &&
|
|
42
|
+
Date.parse(grant.starts_at) <= now &&
|
|
43
|
+
(!grant.expires_at || Date.parse(grant.expires_at) > now)
|
|
44
|
+
);
|
|
45
|
+
}
|
package/src/api-proxy-guard.ts
CHANGED
|
@@ -11,7 +11,12 @@ import {
|
|
|
11
11
|
getCachedTrustEntries,
|
|
12
12
|
hasCachedIpBlockAppealRelief,
|
|
13
13
|
} from './abuse-protection/edge-trust';
|
|
14
|
+
import {
|
|
15
|
+
isFinanceInvoiceCreateSupportRead,
|
|
16
|
+
isFinanceRead,
|
|
17
|
+
} from './api-proxy-read-policy';
|
|
14
18
|
import { DEV_MODE, MAX_PAYLOAD_SIZE } from './constants';
|
|
19
|
+
import { guardOfflineDownloadRequest } from './offline-download-guard';
|
|
15
20
|
import { validateRequestEmojiLimit } from './request-emoji-limit';
|
|
16
21
|
import { enforceRequiredMfaRequest } from './required-mfa-runtime';
|
|
17
22
|
import {
|
|
@@ -112,23 +117,6 @@ type RateLimiter = {
|
|
|
112
117
|
const limiterCache = new Map<string, Limiters>();
|
|
113
118
|
const GENERIC_SUPABASE_AUTH_COOKIE_NAME_PATTERN =
|
|
114
119
|
/^sb-[a-z0-9-]+-auth-token(?:\.\d+)?$/i;
|
|
115
|
-
const UUID_PATH_SEGMENT =
|
|
116
|
-
'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}';
|
|
117
|
-
const FINANCE_INVOICE_CREATE_SUPPORT_READ_PATH_PATTERN = new RegExp(
|
|
118
|
-
`^/api/v1/workspaces/[^/]+/(?:finance/invoices(?:/subscription/context)?|inventory/products|promotions|settings/(?:configs|[^/]+)|user-groups(?:/linked-products|/${UUID_PATH_SEGMENT}/linked-products)|users(?:/${UUID_PATH_SEGMENT}(?:/(?:linked-promotions|referral-discounts|user-groups))?)?|wallets)/?$`,
|
|
119
|
-
'u'
|
|
120
|
-
);
|
|
121
|
-
const FINANCE_INVOICE_TRANSACTION_CATEGORIES_PATH_PATTERN =
|
|
122
|
-
/^\/api\/workspaces\/[^/]+\/transactions\/categories\/?$/u;
|
|
123
|
-
const FINANCE_READ_PATH_PATTERNS = [
|
|
124
|
-
/^\/api\/workspaces\/[^/]+\/finance(?:\/|$)/u,
|
|
125
|
-
/^\/api\/workspaces\/[^/]+\/transactions(?:\/|$)/u,
|
|
126
|
-
/^\/api\/workspaces\/[^/]+\/wallets(?:\/|$)/u,
|
|
127
|
-
/^\/api\/workspaces\/[^/]+\/tags(?:\/|$)/u,
|
|
128
|
-
/^\/api\/v1\/workspaces\/[^/]+\/finance(?:\/|$)/u,
|
|
129
|
-
/^\/api\/v1\/workspaces\/[^/]+\/wallets(?:\/|$)/u,
|
|
130
|
-
] as const;
|
|
131
|
-
|
|
132
120
|
const NO_READ_RATE_LIMITS: RateLimitConfig[] = [];
|
|
133
121
|
|
|
134
122
|
function parsePositiveIntEnv(
|
|
@@ -391,31 +379,6 @@ const FINANCE_READ_RATE_LIMITS: RateLimitProfile = {
|
|
|
391
379
|
mutate: DEFAULT_MUTATE_RATE_LIMITS,
|
|
392
380
|
};
|
|
393
381
|
|
|
394
|
-
function isFinanceInvoiceCreateSupportRead(req: NextRequest) {
|
|
395
|
-
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
396
|
-
return false;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
return (
|
|
400
|
-
FINANCE_INVOICE_CREATE_SUPPORT_READ_PATH_PATTERN.test(
|
|
401
|
-
req.nextUrl.pathname
|
|
402
|
-
) ||
|
|
403
|
-
FINANCE_INVOICE_TRANSACTION_CATEGORIES_PATH_PATTERN.test(
|
|
404
|
-
req.nextUrl.pathname
|
|
405
|
-
)
|
|
406
|
-
);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
function isFinanceRead(req: NextRequest) {
|
|
410
|
-
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
411
|
-
return false;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
return FINANCE_READ_PATH_PATTERNS.some((pattern) =>
|
|
415
|
-
pattern.test(req.nextUrl.pathname)
|
|
416
|
-
);
|
|
417
|
-
}
|
|
418
|
-
|
|
419
382
|
function isUsersDatabaseReadOverPost(req: NextRequest) {
|
|
420
383
|
if (req.method !== 'POST') {
|
|
421
384
|
return false;
|
|
@@ -1352,6 +1315,9 @@ export async function guardApiProxyRequest(
|
|
|
1352
1315
|
}
|
|
1353
1316
|
}
|
|
1354
1317
|
|
|
1318
|
+
const offlineResponse = await guardOfflineDownloadRequest(req);
|
|
1319
|
+
if (offlineResponse) return offlineResponse;
|
|
1320
|
+
|
|
1355
1321
|
const allowDescriptionYjsState =
|
|
1356
1322
|
/\/api\/v1\/workspaces\/[^/]+\/tasks\/[^/]+\/description$/.test(
|
|
1357
1323
|
req.nextUrl.pathname
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { NextRequest } from 'next/server';
|
|
2
|
+
|
|
3
|
+
const UUID_PATH_SEGMENT =
|
|
4
|
+
'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}';
|
|
5
|
+
const FINANCE_INVOICE_CREATE_SUPPORT_READ_PATH_PATTERN = new RegExp(
|
|
6
|
+
`^/api/v1/workspaces/[^/]+/(?:finance/invoices(?:/subscription/context)?|inventory/products|promotions|settings/(?:configs|(?!permissions(?:/|$)|members(?:/|$))[^/]+)|user-groups(?:/linked-products|/${UUID_PATH_SEGMENT}/linked-products)|users(?:/${UUID_PATH_SEGMENT}(?:/(?:linked-promotions|referral-discounts|user-groups))?)?|wallets)/?$`,
|
|
7
|
+
'u'
|
|
8
|
+
);
|
|
9
|
+
const FINANCE_INVOICE_TRANSACTION_CATEGORIES_PATH_PATTERN =
|
|
10
|
+
/^\/api\/workspaces\/[^/]+\/transactions\/categories\/?$/u;
|
|
11
|
+
const FINANCE_READ_PATH_PATTERNS = [
|
|
12
|
+
/^\/api\/workspaces\/[^/]+\/finance(?:\/|$)/u,
|
|
13
|
+
/^\/api\/workspaces\/[^/]+\/transactions(?:\/|$)/u,
|
|
14
|
+
/^\/api\/workspaces\/[^/]+\/wallets(?:\/|$)/u,
|
|
15
|
+
/^\/api\/workspaces\/[^/]+\/tags(?:\/|$)/u,
|
|
16
|
+
/^\/api\/v1\/workspaces\/[^/]+\/finance(?:\/|$)/u,
|
|
17
|
+
/^\/api\/v1\/workspaces\/[^/]+\/wallets(?:\/|$)/u,
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
export function isFinanceInvoiceCreateSupportRead(req: NextRequest) {
|
|
21
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
FINANCE_INVOICE_CREATE_SUPPORT_READ_PATH_PATTERN.test(
|
|
27
|
+
req.nextUrl.pathname
|
|
28
|
+
) ||
|
|
29
|
+
FINANCE_INVOICE_TRANSACTION_CATEGORIES_PATH_PATTERN.test(
|
|
30
|
+
req.nextUrl.pathname
|
|
31
|
+
)
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isFinanceRead(req: NextRequest) {
|
|
36
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return FINANCE_READ_PATH_PATTERNS.some((pattern) =>
|
|
41
|
+
pattern.test(req.nextUrl.pathname)
|
|
42
|
+
);
|
|
43
|
+
}
|
package/src/constants.ts
CHANGED
|
@@ -79,6 +79,9 @@ export const MAX_TASK_NAME_LENGTH = 1024;
|
|
|
79
79
|
export const MAX_TASK_DESCRIPTION_LENGTH = 100000;
|
|
80
80
|
export const MAX_CHAT_MESSAGE_LENGTH = 10000;
|
|
81
81
|
export const MAX_SUPPORT_INQUIRY_LENGTH = 5000;
|
|
82
|
+
export const MAX_SUPPORT_INQUIRY_MESSAGE_LENGTH = 512;
|
|
83
|
+
export const MAX_SUPPORT_INQUIRY_NAME_LENGTH = 64;
|
|
84
|
+
export const MAX_SUPPORT_INQUIRY_SUBJECT_LENGTH = 128;
|
|
82
85
|
export const MAX_CALENDAR_EVENT_TITLE_LENGTH = 255;
|
|
83
86
|
export const MAX_CALENDAR_EVENT_DESCRIPTION_LENGTH = 10000;
|
|
84
87
|
export const MAX_NOTE_TITLE_LENGTH = 255;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { calendar_v3 } from '@tuturuuu/google';
|
|
2
|
+
import type { SupabaseClient } from '@tuturuuu/supabase';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import {
|
|
5
|
+
applyGoogleImport,
|
|
6
|
+
captureGoogleImport,
|
|
7
|
+
GoogleImportFenceError,
|
|
8
|
+
replayDeferredGoogleImports,
|
|
9
|
+
} from './google-calendar-import-fence';
|
|
10
|
+
|
|
11
|
+
const scope = {
|
|
12
|
+
wsId: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa',
|
|
13
|
+
calendarId: 'calendar',
|
|
14
|
+
authTokenId: 'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb',
|
|
15
|
+
};
|
|
16
|
+
const capture = { ...scope, id: 'cccccccc-cccc-4ccc-cccc-cccccccccccc' };
|
|
17
|
+
const result = { inserted: 0, updated: 1, deleted: 0, deferred: 0 };
|
|
18
|
+
|
|
19
|
+
function fixture(providerEvent: unknown = { id: 'event', summary: 'fresh' }) {
|
|
20
|
+
const order: string[] = [];
|
|
21
|
+
const rpc = vi.fn(
|
|
22
|
+
async (
|
|
23
|
+
name: string,
|
|
24
|
+
_args: Record<string, unknown>
|
|
25
|
+
): Promise<{ data: unknown; error: null }> => {
|
|
26
|
+
order.push(name);
|
|
27
|
+
return {
|
|
28
|
+
data:
|
|
29
|
+
name === 'list_deferred_calendar_google_imports'
|
|
30
|
+
? ['event']
|
|
31
|
+
: name === 'capture_calendar_google_import'
|
|
32
|
+
? capture
|
|
33
|
+
: result,
|
|
34
|
+
error: null,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
);
|
|
38
|
+
const get = vi.fn(async () => {
|
|
39
|
+
order.push('get');
|
|
40
|
+
return { data: providerEvent };
|
|
41
|
+
});
|
|
42
|
+
const format = vi.fn(async (events: unknown[]) => events as object[]);
|
|
43
|
+
const client = { rpc } as unknown as SupabaseClient;
|
|
44
|
+
const calendarGet = vi.fn(async () => {
|
|
45
|
+
order.push('calendar-get');
|
|
46
|
+
return { data: { id: scope.calendarId, accessRole: 'reader' } };
|
|
47
|
+
});
|
|
48
|
+
const calendar = {
|
|
49
|
+
events: { get },
|
|
50
|
+
calendarList: { get: calendarGet },
|
|
51
|
+
} as unknown as calendar_v3.Calendar;
|
|
52
|
+
return { client, calendar, scope, format, order, rpc, get, calendarGet };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('guarded Google import boundaries', () => {
|
|
56
|
+
it('captures before the fresh provider read and persists before returning', async () => {
|
|
57
|
+
const f = fixture();
|
|
58
|
+
await replayDeferredGoogleImports(f);
|
|
59
|
+
expect(f.order).toEqual([
|
|
60
|
+
'list_deferred_calendar_google_imports',
|
|
61
|
+
'capture_calendar_google_import',
|
|
62
|
+
'get',
|
|
63
|
+
'apply_calendar_google_import',
|
|
64
|
+
]);
|
|
65
|
+
expect(f.rpc.mock.calls[2]?.[1]).toEqual({
|
|
66
|
+
p_capture_id: capture.id,
|
|
67
|
+
p_events: [{ id: 'event', summary: 'fresh' }],
|
|
68
|
+
p_tombstones: [],
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
it('rejects a capture scoped to another account before any provider read', async () => {
|
|
72
|
+
const f = fixture();
|
|
73
|
+
f.rpc.mockImplementation(async (name) => ({
|
|
74
|
+
data:
|
|
75
|
+
name === 'list_deferred_calendar_google_imports'
|
|
76
|
+
? ['event']
|
|
77
|
+
: { ...capture, authTokenId: null },
|
|
78
|
+
error: null,
|
|
79
|
+
}));
|
|
80
|
+
await expect(replayDeferredGoogleImports(f)).rejects.toBeInstanceOf(
|
|
81
|
+
GoogleImportFenceError
|
|
82
|
+
);
|
|
83
|
+
expect(f.get).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
it('does not apply an unexpected provider event identity', async () => {
|
|
86
|
+
const f = fixture({ id: 'another-event' });
|
|
87
|
+
await expect(replayDeferredGoogleImports(f)).rejects.toBeInstanceOf(
|
|
88
|
+
GoogleImportFenceError
|
|
89
|
+
);
|
|
90
|
+
expect(f.format).not.toHaveBeenCalled();
|
|
91
|
+
expect(f.rpc).toHaveBeenCalledTimes(2);
|
|
92
|
+
});
|
|
93
|
+
it.each([404, 410])(
|
|
94
|
+
'confirms calendar access before treating %s as a tombstone',
|
|
95
|
+
async (status) => {
|
|
96
|
+
const f = fixture();
|
|
97
|
+
f.get.mockRejectedValue({ response: { status } });
|
|
98
|
+
await replayDeferredGoogleImports(f);
|
|
99
|
+
expect(f.rpc.mock.calls[2]?.[1]).toEqual({
|
|
100
|
+
p_capture_id: capture.id,
|
|
101
|
+
p_events: [],
|
|
102
|
+
p_tombstones: ['event'],
|
|
103
|
+
});
|
|
104
|
+
expect(f.format).not.toHaveBeenCalled();
|
|
105
|
+
expect(f.calendarGet).toHaveBeenCalledWith({
|
|
106
|
+
calendarId: scope.calendarId,
|
|
107
|
+
});
|
|
108
|
+
expect(f.order).toEqual([
|
|
109
|
+
'list_deferred_calendar_google_imports',
|
|
110
|
+
'capture_calendar_google_import',
|
|
111
|
+
'calendar-get',
|
|
112
|
+
'apply_calendar_google_import',
|
|
113
|
+
]);
|
|
114
|
+
}
|
|
115
|
+
);
|
|
116
|
+
it.each([403, 404, 410, 503])(
|
|
117
|
+
'keeps a missing event deferred when calendar access fails with %s',
|
|
118
|
+
async (status) => {
|
|
119
|
+
const f = fixture();
|
|
120
|
+
f.get.mockRejectedValue({ response: { status: 404 } });
|
|
121
|
+
f.calendarGet.mockRejectedValue({ response: { status } });
|
|
122
|
+
await expect(replayDeferredGoogleImports(f)).resolves.toEqual({
|
|
123
|
+
deferred: 1,
|
|
124
|
+
});
|
|
125
|
+
expect(f.rpc).toHaveBeenCalledTimes(2);
|
|
126
|
+
expect(f.format).not.toHaveBeenCalled();
|
|
127
|
+
}
|
|
128
|
+
);
|
|
129
|
+
it.each(['freeBusyReader', 'none', ''])(
|
|
130
|
+
'does not mistake %s metadata access for event access',
|
|
131
|
+
async (accessRole) => {
|
|
132
|
+
const f = fixture();
|
|
133
|
+
f.get.mockRejectedValue({ response: { status: 404 } });
|
|
134
|
+
f.calendarGet.mockResolvedValue({
|
|
135
|
+
data: { id: scope.calendarId, accessRole },
|
|
136
|
+
});
|
|
137
|
+
await expect(replayDeferredGoogleImports(f)).resolves.toEqual({
|
|
138
|
+
deferred: 1,
|
|
139
|
+
});
|
|
140
|
+
expect(f.rpc).toHaveBeenCalledTimes(2);
|
|
141
|
+
}
|
|
142
|
+
);
|
|
143
|
+
it('keeps deferred entries when calendar metadata has a different identity', async () => {
|
|
144
|
+
const f = fixture();
|
|
145
|
+
f.get.mockRejectedValue({ response: { status: 404 } });
|
|
146
|
+
f.calendarGet.mockResolvedValue({
|
|
147
|
+
data: { id: 'another-calendar', accessRole: 'reader' },
|
|
148
|
+
});
|
|
149
|
+
await expect(replayDeferredGoogleImports(f)).resolves.toEqual({
|
|
150
|
+
deferred: 1,
|
|
151
|
+
});
|
|
152
|
+
expect(f.rpc).toHaveBeenCalledTimes(2);
|
|
153
|
+
});
|
|
154
|
+
it('leaves deletion-disabled entries queued without an apply', async () => {
|
|
155
|
+
const f = fixture({ id: 'event', status: 'cancelled' });
|
|
156
|
+
expect(
|
|
157
|
+
await replayDeferredGoogleImports({ ...f, syncDeletes: false })
|
|
158
|
+
).toEqual({ deferred: 1 });
|
|
159
|
+
expect(f.rpc).toHaveBeenCalledTimes(2);
|
|
160
|
+
});
|
|
161
|
+
it('propagates provider failure without tombstones or persistence', async () => {
|
|
162
|
+
const f = fixture();
|
|
163
|
+
const failure = new Error('provider unavailable');
|
|
164
|
+
f.get.mockRejectedValue(failure);
|
|
165
|
+
await expect(replayDeferredGoogleImports(f)).rejects.toBe(failure);
|
|
166
|
+
expect(f.rpc).toHaveBeenCalledTimes(2);
|
|
167
|
+
});
|
|
168
|
+
it('fails on optional-looking malformed persistence rather than advancing sync', async () => {
|
|
169
|
+
const f = fixture();
|
|
170
|
+
f.rpc.mockResolvedValue({ data: { ...result, deferred: -1 }, error: null });
|
|
171
|
+
await expect(
|
|
172
|
+
applyGoogleImport(f.client, capture, [])
|
|
173
|
+
).rejects.toBeInstanceOf(GoogleImportFenceError);
|
|
174
|
+
});
|
|
175
|
+
it('rejects malformed capture state', async () => {
|
|
176
|
+
const f = fixture();
|
|
177
|
+
f.rpc.mockResolvedValue({ data: {}, error: null });
|
|
178
|
+
await expect(captureGoogleImport(f.client, scope)).rejects.toBeInstanceOf(
|
|
179
|
+
GoogleImportFenceError
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { calendar_v3 } from '@tuturuuu/google';
|
|
2
|
+
import type { SupabaseClient } from '@tuturuuu/supabase';
|
|
3
|
+
import type { Json } from '@tuturuuu/types/db';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
const CaptureSchema = z
|
|
7
|
+
.object({
|
|
8
|
+
id: z.guid(),
|
|
9
|
+
wsId: z.guid(),
|
|
10
|
+
calendarId: z.string().min(1),
|
|
11
|
+
authTokenId: z.guid().nullable(),
|
|
12
|
+
})
|
|
13
|
+
.strict();
|
|
14
|
+
const ResultSchema = z
|
|
15
|
+
.object({
|
|
16
|
+
inserted: z.number().int().nonnegative(),
|
|
17
|
+
updated: z.number().int().nonnegative(),
|
|
18
|
+
deleted: z.number().int().nonnegative(),
|
|
19
|
+
deferred: z.number().int().nonnegative(),
|
|
20
|
+
})
|
|
21
|
+
.strict();
|
|
22
|
+
export type GoogleImportCapture = z.infer<typeof CaptureSchema>;
|
|
23
|
+
export type GoogleImportResult = z.infer<typeof ResultSchema>;
|
|
24
|
+
export type GoogleImportScope = Omit<GoogleImportCapture, 'id'>;
|
|
25
|
+
|
|
26
|
+
export class GoogleImportFenceError extends Error {
|
|
27
|
+
constructor() {
|
|
28
|
+
super('Google import guard is unavailable; retry sync');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Must run before any provider GET/list, including deferred replay reads. */
|
|
33
|
+
export async function captureGoogleImport(
|
|
34
|
+
client: SupabaseClient,
|
|
35
|
+
scope: GoogleImportScope
|
|
36
|
+
) {
|
|
37
|
+
const { data, error } = await client.rpc('capture_calendar_google_import', {
|
|
38
|
+
p_ws_id: scope.wsId,
|
|
39
|
+
p_calendar_id: scope.calendarId,
|
|
40
|
+
p_auth_token_id: scope.authTokenId,
|
|
41
|
+
});
|
|
42
|
+
const parsed = CaptureSchema.safeParse(data);
|
|
43
|
+
if (
|
|
44
|
+
error ||
|
|
45
|
+
!parsed.success ||
|
|
46
|
+
parsed.data.wsId !== scope.wsId ||
|
|
47
|
+
parsed.data.calendarId !== scope.calendarId ||
|
|
48
|
+
parsed.data.authTokenId !== scope.authTokenId
|
|
49
|
+
)
|
|
50
|
+
throw new GoogleImportFenceError();
|
|
51
|
+
return parsed.data;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** RPC commits applied rows and deferred identities together before token advance. */
|
|
55
|
+
export async function applyGoogleImport(
|
|
56
|
+
client: SupabaseClient,
|
|
57
|
+
capture: GoogleImportCapture,
|
|
58
|
+
events: object[],
|
|
59
|
+
tombstones: string[] = []
|
|
60
|
+
) {
|
|
61
|
+
const { data, error } = await client.rpc('apply_calendar_google_import', {
|
|
62
|
+
p_capture_id: capture.id,
|
|
63
|
+
p_events: events as Json[],
|
|
64
|
+
p_tombstones: tombstones,
|
|
65
|
+
});
|
|
66
|
+
const parsed = ResultSchema.safeParse(data);
|
|
67
|
+
if (error || !parsed.success) throw new GoogleImportFenceError();
|
|
68
|
+
return parsed.data;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function missingProviderEvent(error: unknown) {
|
|
72
|
+
if (!error || typeof error !== 'object') return false;
|
|
73
|
+
const value = error as { code?: unknown; response?: { status?: unknown } };
|
|
74
|
+
return [404, 410].includes(Number(value.response?.status ?? value.code));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Only exact verified token-row scope may replay protected entries. Unknown
|
|
78
|
+
* legacy account scope remains deferred; it never borrows a guessed account. */
|
|
79
|
+
export async function replayDeferredGoogleImports(args: {
|
|
80
|
+
client: SupabaseClient;
|
|
81
|
+
calendar: calendar_v3.Calendar;
|
|
82
|
+
scope: GoogleImportScope;
|
|
83
|
+
format: (events: calendar_v3.Schema$Event[]) => Promise<object[]>;
|
|
84
|
+
syncDeletes?: boolean;
|
|
85
|
+
}) {
|
|
86
|
+
const { data, error } = await args.client.rpc(
|
|
87
|
+
'list_deferred_calendar_google_imports',
|
|
88
|
+
{
|
|
89
|
+
p_ws_id: args.scope.wsId,
|
|
90
|
+
p_calendar_id: args.scope.calendarId,
|
|
91
|
+
p_auth_token_id: args.scope.authTokenId,
|
|
92
|
+
}
|
|
93
|
+
);
|
|
94
|
+
const parsed = z.array(z.string().min(1)).max(100).safeParse(data);
|
|
95
|
+
if (error || !parsed.success) throw new GoogleImportFenceError();
|
|
96
|
+
let deferred = 0;
|
|
97
|
+
for (const id of parsed.data) {
|
|
98
|
+
// Never apply the stored old snapshot. The queue stores identity only.
|
|
99
|
+
const capture = await captureGoogleImport(args.client, args.scope);
|
|
100
|
+
let providerEvent: calendar_v3.Schema$Event | null;
|
|
101
|
+
try {
|
|
102
|
+
const result = await args.calendar.events.get({
|
|
103
|
+
calendarId: args.scope.calendarId,
|
|
104
|
+
eventId: id,
|
|
105
|
+
});
|
|
106
|
+
if (result.data.id !== id) throw new GoogleImportFenceError();
|
|
107
|
+
providerEvent = result.data.status === 'cancelled' ? null : result.data;
|
|
108
|
+
} catch (failure) {
|
|
109
|
+
if (!missingProviderEvent(failure)) throw failure;
|
|
110
|
+
// Missing events and inaccessible/deleted calendars share 404/410. Only
|
|
111
|
+
// confirmed access to this exact calendar can justify a local tombstone.
|
|
112
|
+
try {
|
|
113
|
+
const access = await args.calendar.calendarList.get({
|
|
114
|
+
calendarId: args.scope.calendarId,
|
|
115
|
+
});
|
|
116
|
+
if (
|
|
117
|
+
!['reader', 'writer', 'owner'].includes(
|
|
118
|
+
access.data.accessRole ?? ''
|
|
119
|
+
) ||
|
|
120
|
+
!access.data.id ||
|
|
121
|
+
(args.scope.calendarId !== 'primary' &&
|
|
122
|
+
access.data.id !== args.scope.calendarId)
|
|
123
|
+
) {
|
|
124
|
+
deferred++;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
deferred++;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
providerEvent = null;
|
|
132
|
+
}
|
|
133
|
+
if (!providerEvent && args.syncDeletes === false) {
|
|
134
|
+
deferred++;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const result = await applyGoogleImport(
|
|
138
|
+
args.client,
|
|
139
|
+
capture,
|
|
140
|
+
providerEvent ? await args.format([providerEvent]) : [],
|
|
141
|
+
providerEvent ? [] : [id]
|
|
142
|
+
);
|
|
143
|
+
deferred += result.deferred;
|
|
144
|
+
}
|
|
145
|
+
return { deferred };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Resolve a background caller's exact stored token row from its supplied
|
|
149
|
+
* credential, without persisting or logging that credential. Ambiguity is null. */
|
|
150
|
+
export async function verifiedGoogleImportToken(
|
|
151
|
+
client: SupabaseClient,
|
|
152
|
+
wsId: string,
|
|
153
|
+
accessToken: string
|
|
154
|
+
) {
|
|
155
|
+
const { data, error } = await client
|
|
156
|
+
.from('calendar_auth_tokens')
|
|
157
|
+
.select('id')
|
|
158
|
+
.eq('ws_id', wsId)
|
|
159
|
+
.eq('provider', 'google')
|
|
160
|
+
.eq('is_active', true)
|
|
161
|
+
.eq('access_token', accessToken)
|
|
162
|
+
.limit(2);
|
|
163
|
+
if (error) throw new GoogleImportFenceError();
|
|
164
|
+
const token = data?.length === 1 ? data[0] : undefined;
|
|
165
|
+
return token ? z.guid().parse(token.id) : null;
|
|
166
|
+
}
|