@tuturuuu/utils 0.33.3 → 0.35.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/package.json +6 -4
  3. package/src/__tests__/api-proxy-bearer-trust.test.ts +3 -0
  4. package/src/__tests__/api-proxy-guard.test.ts +6 -13
  5. package/src/__tests__/api-proxy-offline-response.test.ts +48 -0
  6. package/src/__tests__/api-proxy-test-request.ts +19 -0
  7. package/src/__tests__/calendar-provider-readonly.test.ts +45 -0
  8. package/src/__tests__/calendar-recurrence-edit.test.ts +140 -0
  9. package/src/__tests__/calendar-recurrence.test.ts +479 -0
  10. package/src/__tests__/daily-operation-download-isolation.test.ts +137 -0
  11. package/src/__tests__/offline-download-guard.test.ts +340 -0
  12. package/src/__tests__/permissions.test.tsx +21 -0
  13. package/src/__tests__/request-workspace.test.ts +63 -0
  14. package/src/__tests__/tutoring-teacher-hours.test.ts +274 -0
  15. package/src/__tests__/username-policy.test.ts +70 -0
  16. package/src/account-benefits-server.ts +43 -0
  17. package/src/account-benefits.ts +45 -0
  18. package/src/api-proxy-guard.ts +8 -42
  19. package/src/api-proxy-read-policy.ts +43 -0
  20. package/src/calendar-provider-readonly.ts +49 -0
  21. package/src/calendar-recurrence-edit.ts +148 -0
  22. package/src/calendar-recurrence-expansion.ts +131 -0
  23. package/src/calendar-recurrence-local.ts +45 -0
  24. package/src/calendar-recurrence-pattern.ts +54 -0
  25. package/src/calendar-recurrence-providers.ts +361 -0
  26. package/src/calendar-recurrence-validation.ts +180 -0
  27. package/src/calendar-recurrence.ts +5 -0
  28. package/src/calendar-utils.ts +41 -3
  29. package/src/common/metadata.test.ts +0 -6
  30. package/src/constants.ts +3 -0
  31. package/src/deployment-vault-permissions.tsx +26 -0
  32. package/src/desktop-signing-profiles.ts +20 -0
  33. package/src/google-calendar-import-fence.test.ts +182 -0
  34. package/src/google-calendar-import-fence.ts +166 -0
  35. package/src/next-config.ts +8 -1
  36. package/src/offline-download-guard.ts +160 -0
  37. package/src/permissions.tsx +2 -10
  38. package/src/platform-release.test.ts +1 -1
  39. package/src/platform-release.ts +1 -1
  40. package/src/playground-schema.test.ts +119 -0
  41. package/src/playground-schema.ts +173 -0
  42. package/src/playground-templates.ts +65 -0
  43. package/src/release-note-policy-core.cjs +28 -0
  44. package/src/release-note-policy-core.d.cts +5 -0
  45. package/src/release-note-policy.json +9 -0
  46. package/src/release-note-policy.ts +4 -0
  47. package/src/request-workspace.ts +29 -0
  48. package/src/seo-policy.json +196 -0
  49. package/src/seo.test.ts +168 -0
  50. package/src/seo.ts +49 -0
  51. package/src/tutoring-teacher-hours.ts +220 -0
  52. package/src/username-policy.json +87 -0
  53. package/src/username-policy.ts +18 -0
  54. package/vitest.config.ts +7 -0
@@ -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
+ }
@@ -2,6 +2,7 @@ import type { NextConfig } from 'next';
2
2
  import { resolveInternalAppUrl } from './app-url';
3
3
  import { getLocalInternalAppUrl } from './internal-domains';
4
4
  import { getTuturuuuPortlessAllowedDevOrigins } from './portless';
5
+ import { createSeoHeaders, type SeoApp } from './seo';
5
6
 
6
7
  type Environment = Record<string, string | undefined>;
7
8
 
@@ -135,6 +136,10 @@ export function getTuturuuuNextOptimizePackageImports(
135
136
  }
136
137
 
137
138
  export interface TuturuuuNextConfigOptions extends NextConfig {
139
+ /** Explicit app policy; private routes receive an HTTP noindex directive. */
140
+ seoApp?: SeoApp;
141
+ /** Literal path regexes without a leading slash, for generated public pages. */
142
+ seoPublicPathPatterns?: readonly string[];
138
143
  /**
139
144
  * Path patterns (no leading slash, regex fragments) that this app allows to
140
145
  * be framed by third-party sites. Excluded from the platform-wide
@@ -150,7 +155,8 @@ export function createTuturuuuNextConfig(
150
155
  ): NextConfig {
151
156
  const experimentalConfig = config.experimental ?? {};
152
157
  const imageConfig = config.images ?? {};
153
- const { framablePathPatterns, ...nextConfig } = config;
158
+ const { framablePathPatterns, seoApp, seoPublicPathPatterns, ...nextConfig } =
159
+ config;
154
160
  const antiFramingSource = buildAntiFramingSource([
155
161
  ...TUTURUUU_DEFAULT_FRAMABLE_PATTERNS,
156
162
  ...(framablePathPatterns ?? []),
@@ -204,6 +210,7 @@ export function createTuturuuuNextConfig(
204
210
  headers: TUTURUUU_ANTI_FRAMING_HEADERS,
205
211
  },
206
212
  ...((await nextConfig.headers?.()) ?? []),
213
+ ...(seoApp ? createSeoHeaders(seoApp, seoPublicPathPatterns) : []),
207
214
  ];
208
215
  },
209
216
  };
@@ -0,0 +1,160 @@
1
+ import { verifyTurnstileToken } from '@tuturuuu/turnstile/server';
2
+ import { Ratelimit } from '@upstash/ratelimit';
3
+ import type { NextRequest } from 'next/server';
4
+ import { NextResponse } from 'next/server';
5
+ import { extractIPFromRequest } from './abuse-protection/edge';
6
+ import { getCachedTrustEntries } from './abuse-protection/edge-trust';
7
+ import {
8
+ buildProxySessionSubjectKey,
9
+ getProxySessionSubjectKeyFromCookieHeader,
10
+ } from './api-proxy-guard';
11
+ import {
12
+ getUpstashRatelimitRedisClient,
13
+ getUpstashRestRedisClient,
14
+ } from './upstash-rest';
15
+
16
+ export const OFFLINE_DOWNLOAD_HEADER = 'x-tuturuuu-offline-download';
17
+ const CLEARANCE_SECONDS = 15 * 60;
18
+ const READ_PATH =
19
+ /^\/api\/(?:v1\/)?workspaces\/[^/]+\/(?:finance|inventory|transactions|wallets|tags|members|products|product-categories|product-units|product-warehouses|tasks|task-boards|boards|labels|task-projects|task-initiatives|habit-trackers|habits|calendar)(?:[-/]|$)/u;
20
+ const USER_READ_PATH =
21
+ /^\/api\/v1\/(?:users\/me\/tasks|calendar|users\/calendar-settings)(?:\/|$)/u;
22
+
23
+ export function isOfflineCapableRead(
24
+ request: Pick<NextRequest, 'method' | 'url'>
25
+ ) {
26
+ if (request.method !== 'GET' && request.method !== 'HEAD') return false;
27
+ const pathname = new URL(request.url).pathname.replace(
28
+ /^\/api\/v1\/mobile-calendar(?=\/api\/)/u,
29
+ ''
30
+ );
31
+ return (
32
+ READ_PATH.test(pathname) ||
33
+ USER_READ_PATH.test(pathname) ||
34
+ /^\/api\/v1\/exchange-rates\/?$/u.test(pathname)
35
+ );
36
+ }
37
+
38
+ function unavailable() {
39
+ return NextResponse.json(
40
+ {
41
+ message:
42
+ 'Download protection is temporarily unavailable. Try again later.',
43
+ },
44
+ {
45
+ status: 503,
46
+ headers: { 'Cache-Control': 'no-store', 'Retry-After': '30' },
47
+ }
48
+ );
49
+ }
50
+
51
+ function challenge() {
52
+ return NextResponse.json(
53
+ {
54
+ code: 'ABUSE_CHALLENGE_REQUIRED',
55
+ message: 'Additional verification is required before retrying.',
56
+ },
57
+ {
58
+ status: 403,
59
+ headers: {
60
+ 'Cache-Control': 'no-store',
61
+ 'X-Abuse-Challenge': 'turnstile',
62
+ },
63
+ }
64
+ );
65
+ }
66
+
67
+ /** Additional protection is scoped to explicitly marked offline bulk downloads.
68
+ * Ordinary enterprise reads retain their existing API/auth protections.
69
+ * Clearance never satisfies authentication, authorization, IP blocks, required
70
+ * MFA, or either rate-limit budget. Runtime failures remain fail-closed. */
71
+ export async function guardOfflineDownloadRequest(request: NextRequest) {
72
+ if (!isOfflineCapableRead(request) || process.env.NODE_ENV !== 'production')
73
+ return null;
74
+ const bulk = request.headers.get(OFFLINE_DOWNLOAD_HEADER) === '1';
75
+ if (!bulk) return null;
76
+ if (process.env.OFFLINE_DOWNLOAD_PROTECTION_ENABLED !== 'true') {
77
+ // Keep baseline API/auth protections while this additional protection is
78
+ // unprovisioned. Offline prefetch is unavailable until explicitly enabled.
79
+ return unavailable();
80
+ }
81
+ try {
82
+ const redis = await getUpstashRestRedisClient();
83
+ const limiterRedis = await getUpstashRatelimitRedisClient();
84
+ if (!redis || !limiterRedis) return unavailable();
85
+ const bearer = request.headers
86
+ .get('authorization')
87
+ ?.match(/^Bearer\s+([^\s]+)$/i)?.[1];
88
+ const sessionKey = bearer
89
+ ? await buildProxySessionSubjectKey('bearer', bearer)
90
+ : await getProxySessionSubjectKeyFromCookieHeader(
91
+ request.headers.get('cookie')
92
+ );
93
+ const ip = extractIPFromRequest(request.headers);
94
+ // IP remains bounded even when a caller rotates unverified bearer values.
95
+ // Internal authenticated gateways may lack a client IP. Never combine
96
+ // their unrelated sessions into a shared unknown-IP bucket. Public ingress
97
+ // still supplies a real IP, retaining the rotating-token budget.
98
+ if (ip === 'unknown' && !sessionKey) return unavailable();
99
+ const subjects = [
100
+ ...(ip !== 'unknown'
101
+ ? [{ key: `ip:${ip}`, minute: 1200, hour: 30000 }]
102
+ : []),
103
+ ...(sessionKey ? [{ key: sessionKey, minute: 600, hour: 12000 }] : []),
104
+ ];
105
+ for (const subject of subjects) {
106
+ for (const [window, limit] of [
107
+ ['1 m', subject.minute],
108
+ ['1 h', subject.hour],
109
+ ] as const) {
110
+ const limiter = new Ratelimit({
111
+ redis: limiterRedis,
112
+ limiter: Ratelimit.slidingWindow(limit, window),
113
+ prefix: `offline:read:${window}`,
114
+ });
115
+ const result = await limiter.limit(subject.key);
116
+ if (!result.success) {
117
+ return NextResponse.json(
118
+ {
119
+ message:
120
+ 'Read limit reached. Try again after the indicated delay.',
121
+ },
122
+ {
123
+ status: 429,
124
+ headers: {
125
+ 'Cache-Control': 'no-store',
126
+ 'Retry-After': `${Math.max(1, Math.ceil((result.reset - Date.now()) / 1000))}`,
127
+ 'X-RateLimit-Policy': 'offline-capable-read',
128
+ },
129
+ }
130
+ );
131
+ }
132
+ }
133
+ }
134
+ if (!sessionKey)
135
+ return NextResponse.json(
136
+ { message: 'Authentication required' },
137
+ { status: 401 }
138
+ );
139
+ const trust = (await getCachedTrustEntries([sessionKey])).get(sessionKey);
140
+ // Reuse established account trust. This check does not write trust markers.
141
+ if (trust?.verified && (trust.m > 1 || trust.mode === 'unlimited'))
142
+ return null;
143
+ const clearanceKey = `offline:clearance:${sessionKey}`;
144
+ if (await redis.get(clearanceKey)) return null;
145
+ const token = request.headers.get('x-tuturuuu-turnstile-token');
146
+ if (!token) return challenge();
147
+ try {
148
+ await verifyTurnstileToken(request, token, {
149
+ remoteIp: ip === 'unknown' ? undefined : ip,
150
+ });
151
+ } catch {
152
+ return challenge();
153
+ }
154
+ await redis.set(clearanceKey, 'passed', { ex: CLEARANCE_SECONDS });
155
+ return null;
156
+ } catch {
157
+ // A distributed guard must not silently fall back to per-process limits.
158
+ return unavailable();
159
+ }
160
+ }
@@ -53,6 +53,7 @@ import type { SupabaseUser } from '@tuturuuu/supabase/next/user';
53
53
  import type { PermissionId } from '@tuturuuu/types';
54
54
  import { ROOT_WORKSPACE_ID } from '@tuturuuu/utils/constants';
55
55
  import type { ReactNode } from 'react';
56
+ import { deploymentVaultPermissions } from './deployment-vault-permissions';
56
57
 
57
58
  export type RolePermission = {
58
59
  id: PermissionId;
@@ -142,16 +143,7 @@ export const permissionGroups = ({
142
143
  disableOnProduction: false,
143
144
  disabled: false,
144
145
  },
145
- {
146
- id: 'manage_mobile_deployment_vault' as PermissionId,
147
- icon: <FileKey2 />,
148
- title: t('ws-roles.manage_mobile_deployment_vault'),
149
- description: t(
150
- 'ws-roles.manage_mobile_deployment_vault_description'
151
- ),
152
- disableOnProduction: false,
153
- disabled: false,
154
- },
146
+ ...deploymentVaultPermissions(t),
155
147
  {
156
148
  id: 'manage_external_migrations',
157
149
  icon: <DatabaseZap />,
@@ -7,7 +7,7 @@ import {
7
7
 
8
8
  describe('platform release metadata', () => {
9
9
  it('uses the centralized shared browser app version', () => {
10
- expect(TUTURUUU_PLATFORM_VERSION).toBe('0.65.0'); // x-release-please-version
10
+ expect(TUTURUUU_PLATFORM_VERSION).toBe('0.67.0'); // x-release-please-version
11
11
  });
12
12
 
13
13
  it('preserves the source timestamp in builtAt and derives a short hash', () => {
@@ -1,6 +1,6 @@
1
1
  import { PLATFORM_BUILD_METADATA } from './generated/platform-build-metadata';
2
2
 
3
- export const TUTURUUU_PLATFORM_VERSION = '0.65.0'; // x-release-please-version
3
+ export const TUTURUUU_PLATFORM_VERSION = '0.67.0'; // x-release-please-version
4
4
 
5
5
  export type PlatformBuildMetadataInput = {
6
6
  /** ISO timestamp of the source commit used for the build, not deployment time. */