@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.
@@ -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
+ }
@@ -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
+ }