@tuturuuu/utils 0.27.1 → 0.29.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 +47 -0
- package/biome.json +1 -1
- package/package.json +18 -17
- package/src/__tests__/api-proxy-guard.test.ts +20 -27
- package/src/__tests__/app-url.test.ts +2 -0
- package/src/__tests__/workspace-principal-resolution.test.ts +34 -0
- package/src/ai-temp-auth-constants.ts +2 -0
- package/src/ai-temp-auth.ts +2 -1
- package/src/api-proxy-guard.ts +0 -15
- package/src/calendar-utils.ts +10 -1
- package/src/commercial-comparison-0.ts +294 -0
- package/src/commercial-comparison-1.ts +283 -0
- package/src/commercial-comparison-2.ts +262 -0
- package/src/commercial-comparison-types.ts +20 -0
- package/src/commercial-comparison.test.ts +32 -0
- package/src/commercial-comparison.ts +96 -0
- package/src/coordination-protocol.ts +61 -0
- package/src/coordination.test.ts +84 -0
- package/src/coordination.ts +57 -0
- package/src/feature-tier-policy.ts +26 -0
- package/src/internal-domains.ts +6 -0
- package/src/keyboard-shortcuts.ts +40 -0
- package/src/launchable-apps.ts +25 -0
- package/src/meet-creation-policy.test.ts +32 -0
- package/src/meet-creation-policy.ts +12 -0
- package/src/meet-duration.ts +71 -0
- package/src/meet-hosting.test.ts +58 -0
- package/src/meet-hosting.ts +19 -0
- package/src/meeting-invitations.test.ts +102 -0
- package/src/meeting-invitations.ts +81 -0
- package/src/onboarding.ts +2 -0
- package/src/platform-release.test.ts +1 -1
- package/src/platform-release.ts +1 -1
- package/src/portless.ts +2 -0
- package/src/task-helper/task-sort.test.ts +22 -3
- package/src/task-helper/task-sort.ts +33 -3
- package/src/workspace-helper.ts +3 -3
- package/src/workspace-limits.ts +1 -1
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
const digest = z.string().regex(/^[a-f0-9]{64}$/);
|
|
4
|
+
const base = {
|
|
5
|
+
key: digest,
|
|
6
|
+
owner: z.string().uuid(),
|
|
7
|
+
};
|
|
8
|
+
export const coordinationRequestSchema = z.union([
|
|
9
|
+
z
|
|
10
|
+
.object({
|
|
11
|
+
...base,
|
|
12
|
+
namespace: z.literal('authenticator'),
|
|
13
|
+
action: z.literal('acquire'),
|
|
14
|
+
})
|
|
15
|
+
.strict(),
|
|
16
|
+
z
|
|
17
|
+
.object({
|
|
18
|
+
...base,
|
|
19
|
+
namespace: z.literal('meeting'),
|
|
20
|
+
action: z.literal('acquire'),
|
|
21
|
+
fingerprint: digest,
|
|
22
|
+
})
|
|
23
|
+
.strict(),
|
|
24
|
+
z
|
|
25
|
+
.object({
|
|
26
|
+
...base,
|
|
27
|
+
namespace: z.enum(['authenticator', 'meeting']),
|
|
28
|
+
action: z.enum(['check', 'release']),
|
|
29
|
+
})
|
|
30
|
+
.strict(),
|
|
31
|
+
z
|
|
32
|
+
.object({
|
|
33
|
+
...base,
|
|
34
|
+
namespace: z.literal('meeting'),
|
|
35
|
+
action: z.literal('complete'),
|
|
36
|
+
})
|
|
37
|
+
.strict(),
|
|
38
|
+
]);
|
|
39
|
+
export type CoordinationRequest = z.infer<typeof coordinationRequestSchema>;
|
|
40
|
+
export const coordinationResultSchema = z.union([
|
|
41
|
+
z
|
|
42
|
+
.object({
|
|
43
|
+
outcome: z.literal('acquired'),
|
|
44
|
+
fresh: z.boolean(),
|
|
45
|
+
completed: z.boolean(),
|
|
46
|
+
})
|
|
47
|
+
.strict(),
|
|
48
|
+
z
|
|
49
|
+
.object({
|
|
50
|
+
outcome: z.enum([
|
|
51
|
+
'busy',
|
|
52
|
+
'conflict',
|
|
53
|
+
'owned',
|
|
54
|
+
'lost',
|
|
55
|
+
'released',
|
|
56
|
+
'completed',
|
|
57
|
+
]),
|
|
58
|
+
})
|
|
59
|
+
.strict(),
|
|
60
|
+
]);
|
|
61
|
+
export type CoordinationResult = z.infer<typeof coordinationResultSchema>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('server-only', () => ({}));
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
CoordinationUnavailableError,
|
|
7
|
+
coordinate,
|
|
8
|
+
coordinationKey,
|
|
9
|
+
} from './coordination';
|
|
10
|
+
|
|
11
|
+
const input = {
|
|
12
|
+
namespace: 'authenticator' as const,
|
|
13
|
+
key: 'a'.repeat(64),
|
|
14
|
+
owner: '0199619a-9154-7000-8000-000000000001',
|
|
15
|
+
action: 'acquire' as const,
|
|
16
|
+
};
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.unstubAllGlobals();
|
|
19
|
+
vi.unstubAllEnvs();
|
|
20
|
+
});
|
|
21
|
+
function setup() {
|
|
22
|
+
vi.stubEnv('CLOUDFLARE_COORDINATION_URL', 'https://coordination.example.com');
|
|
23
|
+
vi.stubEnv(
|
|
24
|
+
'CLOUDFLARE_COORDINATION_TOKEN',
|
|
25
|
+
'test-only-placeholder'.repeat(3)
|
|
26
|
+
);
|
|
27
|
+
const fetchMock = vi
|
|
28
|
+
.fn()
|
|
29
|
+
.mockResolvedValue(
|
|
30
|
+
Response.json({ outcome: 'acquired', fresh: true, completed: false })
|
|
31
|
+
);
|
|
32
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
33
|
+
return fetchMock;
|
|
34
|
+
}
|
|
35
|
+
describe('server coordination client', () => {
|
|
36
|
+
it('hashes identities and sends bounded uncached authenticated requests', async () => {
|
|
37
|
+
const fetchMock = setup();
|
|
38
|
+
expect(coordinationKey('user')).toMatch(/^[a-f0-9]{64}$/);
|
|
39
|
+
await expect(coordinate(input)).resolves.toMatchObject({
|
|
40
|
+
outcome: 'acquired',
|
|
41
|
+
});
|
|
42
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
43
|
+
new URL('https://coordination.example.com/v1/coordinate'),
|
|
44
|
+
expect.objectContaining({
|
|
45
|
+
cache: 'no-store',
|
|
46
|
+
redirect: 'error',
|
|
47
|
+
method: 'POST',
|
|
48
|
+
signal: expect.any(AbortSignal),
|
|
49
|
+
})
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
it('fails closed without configuration or with an insecure URL', async () => {
|
|
53
|
+
const fetchMock = setup();
|
|
54
|
+
for (const url of [
|
|
55
|
+
'',
|
|
56
|
+
'http://example.com',
|
|
57
|
+
'https://user:password@example.com',
|
|
58
|
+
]) {
|
|
59
|
+
vi.stubEnv('CLOUDFLARE_COORDINATION_URL', url);
|
|
60
|
+
await expect(coordinate(input)).rejects.toBeInstanceOf(
|
|
61
|
+
CoordinationUnavailableError
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
it('sanitizes provider failures and does not retry ambiguous mutations', async () => {
|
|
67
|
+
const fetchMock = setup();
|
|
68
|
+
fetchMock.mockRejectedValueOnce(new Error('sensitive provider detail'));
|
|
69
|
+
await expect(coordinate(input)).rejects.toThrow(
|
|
70
|
+
'Shared coordination is temporarily unavailable'
|
|
71
|
+
);
|
|
72
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
73
|
+
fetchMock.mockResolvedValueOnce(Response.json({ unexpected: 'value' }));
|
|
74
|
+
await expect(coordinate(input)).rejects.toBeInstanceOf(
|
|
75
|
+
CoordinationUnavailableError
|
|
76
|
+
);
|
|
77
|
+
fetchMock.mockResolvedValueOnce(
|
|
78
|
+
new Response('private error', { status: 503 })
|
|
79
|
+
);
|
|
80
|
+
await expect(coordinate(input)).rejects.toBeInstanceOf(
|
|
81
|
+
CoordinationUnavailableError
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import {
|
|
5
|
+
type CoordinationRequest,
|
|
6
|
+
coordinationResultSchema,
|
|
7
|
+
} from './coordination-protocol';
|
|
8
|
+
import { Effect } from './effect';
|
|
9
|
+
|
|
10
|
+
export class CoordinationUnavailableError extends Error {
|
|
11
|
+
constructor() {
|
|
12
|
+
super('Shared coordination is temporarily unavailable');
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function coordinationKey(value: string) {
|
|
16
|
+
return createHash('sha256').update(value).digest('hex');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Server-only, bounded requests. Do not retry mutations after an ambiguous
|
|
20
|
+
* network failure: their expiring lease may already have been committed. */
|
|
21
|
+
export async function coordinate(input: CoordinationRequest) {
|
|
22
|
+
const result = await Effect.runPromise(
|
|
23
|
+
Effect.either(
|
|
24
|
+
Effect.tryPromise({
|
|
25
|
+
try: async () => {
|
|
26
|
+
const origin = new URL(process.env.CLOUDFLARE_COORDINATION_URL ?? '');
|
|
27
|
+
const token = process.env.CLOUDFLARE_COORDINATION_TOKEN;
|
|
28
|
+
if (
|
|
29
|
+
origin.protocol !== 'https:' ||
|
|
30
|
+
origin.username ||
|
|
31
|
+
origin.password ||
|
|
32
|
+
!token ||
|
|
33
|
+
token.length < 32
|
|
34
|
+
)
|
|
35
|
+
throw new CoordinationUnavailableError();
|
|
36
|
+
const response = await fetch(new URL('/v1/coordinate', origin), {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: {
|
|
39
|
+
Authorization: `Bearer ${token}`,
|
|
40
|
+
'Content-Type': 'application/json',
|
|
41
|
+
},
|
|
42
|
+
body: JSON.stringify(input),
|
|
43
|
+
cache: 'no-store',
|
|
44
|
+
redirect: 'error',
|
|
45
|
+
signal: AbortSignal.timeout(5000),
|
|
46
|
+
});
|
|
47
|
+
if (!response.ok) throw new CoordinationUnavailableError();
|
|
48
|
+
return coordinationResultSchema.parse(await response.json());
|
|
49
|
+
},
|
|
50
|
+
// Never expose URLs, request bodies, or authorization headers in errors.
|
|
51
|
+
catch: () => new CoordinationUnavailableError(),
|
|
52
|
+
})
|
|
53
|
+
)
|
|
54
|
+
);
|
|
55
|
+
if (result._tag === 'Left') throw result.left;
|
|
56
|
+
return result.right;
|
|
57
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { WorkspaceProductTier } from '@tuturuuu/types/db';
|
|
2
|
+
export const FEATURE_TIERS = {
|
|
3
|
+
// FREE tier features
|
|
4
|
+
dashboard: 'FREE',
|
|
5
|
+
tasks: 'FREE',
|
|
6
|
+
calendar: 'FREE',
|
|
7
|
+
notifications: 'FREE',
|
|
8
|
+
qr_generator: 'FREE',
|
|
9
|
+
finance: 'FREE',
|
|
10
|
+
users: 'FREE',
|
|
11
|
+
inventory: 'FREE',
|
|
12
|
+
whiteboards: 'FREE',
|
|
13
|
+
drive: 'FREE',
|
|
14
|
+
realtime_cursors: 'FREE',
|
|
15
|
+
documents: 'FREE',
|
|
16
|
+
time_tracker: 'FREE',
|
|
17
|
+
voice_assistant: 'FREE',
|
|
18
|
+
|
|
19
|
+
// PLUS tier features
|
|
20
|
+
chat: 'PLUS',
|
|
21
|
+
|
|
22
|
+
// PRO tier features
|
|
23
|
+
workforce: 'PRO',
|
|
24
|
+
ai_lab: 'PRO',
|
|
25
|
+
mira: 'PRO',
|
|
26
|
+
} as const satisfies Record<string, WorkspaceProductTier>;
|
package/src/internal-domains.ts
CHANGED
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
} from './portless';
|
|
5
5
|
|
|
6
6
|
export const PRODUCTION_INTERNAL_APP_DOMAINS = [
|
|
7
|
+
{ name: 'lettin', url: 'https://lettin.tuturuuu.com' },
|
|
8
|
+
{ name: 'colab', url: 'https://colab.tuturuuu.com' },
|
|
7
9
|
{
|
|
8
10
|
name: 'ai',
|
|
9
11
|
url: 'https://ai.tuturuuu.com',
|
|
@@ -131,6 +133,8 @@ export const PRODUCTION_INTERNAL_APP_DOMAINS = [
|
|
|
131
133
|
] as const;
|
|
132
134
|
|
|
133
135
|
export const PORTLESS_INTERNAL_APP_DOMAINS = [
|
|
136
|
+
{ name: 'lettin', url: getTuturuuuPortlessAppOrigin('lettin') },
|
|
137
|
+
{ name: 'colab', url: getTuturuuuPortlessAppOrigin('colab') },
|
|
134
138
|
{
|
|
135
139
|
name: 'ai',
|
|
136
140
|
url: getTuturuuuPortlessAppOrigin('ai'),
|
|
@@ -250,6 +254,8 @@ export const PORTLESS_INTERNAL_APP_DOMAINS = [
|
|
|
250
254
|
] as const;
|
|
251
255
|
|
|
252
256
|
export const LOCALHOST_INTERNAL_APP_DOMAINS = [
|
|
257
|
+
{ name: 'lettin', url: 'http://localhost:7833' },
|
|
258
|
+
{ name: 'colab', url: 'http://localhost:8795' },
|
|
253
259
|
{
|
|
254
260
|
name: 'ai',
|
|
255
261
|
url: 'http://localhost:7829',
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Shared guards for document/window keyboard commands. */
|
|
2
|
+
export function isEditableShortcutTarget(target: EventTarget | null): boolean {
|
|
3
|
+
return (
|
|
4
|
+
typeof Element !== 'undefined' &&
|
|
5
|
+
target instanceof Element &&
|
|
6
|
+
Boolean(
|
|
7
|
+
target.closest(
|
|
8
|
+
'input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="textbox"], [role="combobox"]'
|
|
9
|
+
)
|
|
10
|
+
)
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isShortcutEventIgnored(
|
|
15
|
+
event: Pick<KeyboardEvent, 'defaultPrevented' | 'isComposing' | 'repeat'>
|
|
16
|
+
): boolean {
|
|
17
|
+
return event.defaultPrevented || event.isComposing || event.repeat;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isPageShortcutBlocked(event: KeyboardEvent): boolean {
|
|
21
|
+
if (isShortcutEventIgnored(event) || isEditableShortcutTarget(event.target))
|
|
22
|
+
return true;
|
|
23
|
+
const target = event.target;
|
|
24
|
+
if (
|
|
25
|
+
typeof Element !== 'undefined' &&
|
|
26
|
+
target instanceof Element &&
|
|
27
|
+
target.closest(
|
|
28
|
+
'[role="menu"], [role="listbox"], [role="dialog"], [role="alertdialog"]'
|
|
29
|
+
)
|
|
30
|
+
)
|
|
31
|
+
return true;
|
|
32
|
+
return (
|
|
33
|
+
typeof document !== 'undefined' &&
|
|
34
|
+
Boolean(
|
|
35
|
+
document.querySelector(
|
|
36
|
+
'[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"]'
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
);
|
|
40
|
+
}
|
package/src/launchable-apps.ts
CHANGED
|
@@ -387,6 +387,31 @@ export const LAUNCHABLE_APPS = [
|
|
|
387
387
|
title: 'Track',
|
|
388
388
|
workspacePathResolver: workspaceRootPath,
|
|
389
389
|
},
|
|
390
|
+
{
|
|
391
|
+
aliases: ['Prompt engineering', 'Team workshops', 'Multiplayer learning'],
|
|
392
|
+
appRoot: 'apps/colab',
|
|
393
|
+
category: 'learning',
|
|
394
|
+
defaultPath: '/',
|
|
395
|
+
localhostOrigin: 'http://localhost:8795',
|
|
396
|
+
packageName: '@tuturuuu/colab',
|
|
397
|
+
portlessApp: 'colab',
|
|
398
|
+
productionUrl: 'https://colab.tuturuuu.com',
|
|
399
|
+
slug: 'colab',
|
|
400
|
+
title: 'Colab',
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
aliases: ['Worldbuilding', 'Wiki', 'Characters', 'Stories', 'Bulletin'],
|
|
404
|
+
appRoot: 'apps/lettin',
|
|
405
|
+
category: 'productivity',
|
|
406
|
+
defaultPath: '/dashboard',
|
|
407
|
+
localhostOrigin: 'http://localhost:7833',
|
|
408
|
+
packageName: '@tuturuuu/lettin',
|
|
409
|
+
portlessApp: 'lettin',
|
|
410
|
+
productionUrl: 'https://lettin.tuturuuu.com',
|
|
411
|
+
slug: 'lettin',
|
|
412
|
+
title: '(Tu)lettin',
|
|
413
|
+
workspacePathResolver: workspaceRootPath,
|
|
414
|
+
},
|
|
390
415
|
] as const satisfies readonly LaunchableApp[];
|
|
391
416
|
|
|
392
417
|
export type LaunchableAppSlug = (typeof LAUNCHABLE_APPS)[number]['slug'];
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { canCreateOnlineMeeting } from './meet-creation-policy';
|
|
3
|
+
|
|
4
|
+
describe('online meeting creator domain', () => {
|
|
5
|
+
it.each(['host@tuturuuu.com', 'Host@TUTURUUU.COM'])('allows %s', (email) => {
|
|
6
|
+
expect(canCreateOnlineMeeting(email)).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
it.each([
|
|
9
|
+
null,
|
|
10
|
+
undefined,
|
|
11
|
+
'',
|
|
12
|
+
'@tuturuuu.com',
|
|
13
|
+
'host@gmail.com',
|
|
14
|
+
'host@sub.tuturuuu.com',
|
|
15
|
+
'host@tuturuuu.com.evil.test',
|
|
16
|
+
'host@evil.test@tuturuuu.com',
|
|
17
|
+
' host@tuturuuu.com',
|
|
18
|
+
])('rejects %s', (email) => {
|
|
19
|
+
expect(canCreateOnlineMeeting(email)).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it.each(['PLUS', 'PRO', 'ENTERPRISE'])(
|
|
24
|
+
'allows verified external %s eligibility',
|
|
25
|
+
(tier) => {
|
|
26
|
+
expect(canCreateOnlineMeeting('host@example.test', tier)).toBe(true);
|
|
27
|
+
expect(canCreateOnlineMeeting('bad@@example.test', tier)).toBe(false);
|
|
28
|
+
}
|
|
29
|
+
);
|
|
30
|
+
it('does not unlock hosting for an unknown tier', () => {
|
|
31
|
+
expect(canCreateOnlineMeeting('host@example.test', 'UNKNOWN')).toBe(false);
|
|
32
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Use a current authenticated email and server-resolved personal subscription tier. */
|
|
2
|
+
export function canCreateOnlineMeeting(
|
|
3
|
+
email: string | null | undefined,
|
|
4
|
+
tier: string = 'FREE'
|
|
5
|
+
): boolean {
|
|
6
|
+
if (typeof email !== 'string' || !/^[^\s@]+@[^\s@]+$/u.test(email))
|
|
7
|
+
return false;
|
|
8
|
+
return (
|
|
9
|
+
/^[^\s@]+@tuturuuu\.com$/iu.test(email) ||
|
|
10
|
+
['PLUS', 'PRO', 'ENTERPRISE'].includes(tier)
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
import { createAdminClient } from '@tuturuuu/supabase/next/server';
|
|
3
|
+
import type { WorkspaceProductTier } from '@tuturuuu/types';
|
|
4
|
+
import { extractTierFromSubscriptions } from './workspace-helper';
|
|
5
|
+
export class MeetDurationError extends Error {
|
|
6
|
+
readonly status = 503;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function meetingDurationSeconds(tier: string) {
|
|
10
|
+
return (
|
|
11
|
+
(tier === 'PLUS' ? 10 : tier === 'PRO' || tier === 'ENTERPRISE' ? 24 : 2) *
|
|
12
|
+
3600
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Entitlements belong to the host's personal account, never the joining guest. */
|
|
17
|
+
export async function getHostMeetingTier(
|
|
18
|
+
hostId: string
|
|
19
|
+
): Promise<WorkspaceProductTier> {
|
|
20
|
+
const db = await createAdminClient({ noCookie: true });
|
|
21
|
+
const { data, error } = await db
|
|
22
|
+
.from('workspaces')
|
|
23
|
+
.select('id')
|
|
24
|
+
.eq('creator_id', hostId)
|
|
25
|
+
.eq('personal', true)
|
|
26
|
+
.or('deleted.is.null,deleted.eq.false')
|
|
27
|
+
.maybeSingle();
|
|
28
|
+
if (error) throw new MeetDurationError('Meeting entitlement lookup failed');
|
|
29
|
+
if (!data) return 'FREE';
|
|
30
|
+
const subscriptions = await db
|
|
31
|
+
.from('workspace_subscriptions')
|
|
32
|
+
.select('created_at, status, product_id')
|
|
33
|
+
.eq('ws_id', data.id)
|
|
34
|
+
.eq('status', 'active');
|
|
35
|
+
if (subscriptions.error)
|
|
36
|
+
throw new MeetDurationError('Meeting entitlement lookup failed');
|
|
37
|
+
const ids = [
|
|
38
|
+
...new Set(
|
|
39
|
+
subscriptions.data
|
|
40
|
+
.map((subscription) => subscription.product_id)
|
|
41
|
+
.filter((id): id is string => !!id)
|
|
42
|
+
),
|
|
43
|
+
];
|
|
44
|
+
if (!ids.length) return 'FREE';
|
|
45
|
+
const products = await db
|
|
46
|
+
.schema('private')
|
|
47
|
+
.from('workspace_subscription_products')
|
|
48
|
+
.select('id, tier')
|
|
49
|
+
.in('id', ids);
|
|
50
|
+
if (products.error)
|
|
51
|
+
throw new MeetDurationError('Meeting entitlement lookup failed');
|
|
52
|
+
const tiers = new Map(
|
|
53
|
+
products.data.map((product) => [product.id, product.tier])
|
|
54
|
+
);
|
|
55
|
+
if (ids.some((id) => !tiers.get(id)))
|
|
56
|
+
throw new MeetDurationError('Meeting entitlement product unavailable');
|
|
57
|
+
const tier = extractTierFromSubscriptions(
|
|
58
|
+
subscriptions.data.map((subscription) => ({
|
|
59
|
+
...subscription,
|
|
60
|
+
product_tier: subscription.product_id
|
|
61
|
+
? (tiers.get(subscription.product_id) ?? null)
|
|
62
|
+
: null,
|
|
63
|
+
}))
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
return tier ?? 'FREE';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function getHostMeetingDurationSeconds(hostId: string) {
|
|
70
|
+
return meetingDurationSeconds(await getHostMeetingTier(hostId));
|
|
71
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { beforeEach, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const tier = vi.hoisted(() => vi.fn());
|
|
4
|
+
vi.mock('server-only', () => ({}));
|
|
5
|
+
vi.mock('./meet-duration', () => ({ getHostMeetingTier: tier }));
|
|
6
|
+
|
|
7
|
+
import { canVerifiedAccountHostMeeting } from './meet-hosting';
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
vi.resetAllMocks();
|
|
11
|
+
tier.mockResolvedValue('FREE');
|
|
12
|
+
});
|
|
13
|
+
it.each(['PLUS', 'PRO', 'ENTERPRISE'])(
|
|
14
|
+
'unlocks verified %s accounts',
|
|
15
|
+
async (value) => {
|
|
16
|
+
tier.mockResolvedValue(value);
|
|
17
|
+
expect(
|
|
18
|
+
await canVerifiedAccountHostMeeting('actor', {
|
|
19
|
+
email: 'host@example.com',
|
|
20
|
+
email_confirmed_at: '2026-01-01',
|
|
21
|
+
})
|
|
22
|
+
).toBe(true);
|
|
23
|
+
expect(tier).toHaveBeenCalledWith('actor');
|
|
24
|
+
}
|
|
25
|
+
);
|
|
26
|
+
it('requires verification before considering a paid subscription', async () => {
|
|
27
|
+
tier.mockResolvedValue('PLUS');
|
|
28
|
+
expect(
|
|
29
|
+
await canVerifiedAccountHostMeeting('actor', { email: 'host@example.com' })
|
|
30
|
+
).toBe(false);
|
|
31
|
+
expect(tier).not.toHaveBeenCalled();
|
|
32
|
+
});
|
|
33
|
+
it('preserves verified company hosting without paid entitlement', async () => {
|
|
34
|
+
expect(
|
|
35
|
+
await canVerifiedAccountHostMeeting('actor', {
|
|
36
|
+
email: 'host@tuturuuu.com',
|
|
37
|
+
email_confirmed_at: '2026-01-01',
|
|
38
|
+
})
|
|
39
|
+
).toBe(true);
|
|
40
|
+
expect(tier).not.toHaveBeenCalled();
|
|
41
|
+
});
|
|
42
|
+
it('denies external Free accounts', async () => {
|
|
43
|
+
expect(
|
|
44
|
+
await canVerifiedAccountHostMeeting('actor', {
|
|
45
|
+
email: 'host@example.com',
|
|
46
|
+
email_confirmed_at: '2026-01-01',
|
|
47
|
+
})
|
|
48
|
+
).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
it('fails closed when entitlement cannot be resolved', async () => {
|
|
51
|
+
tier.mockRejectedValue(new Error('Unavailable'));
|
|
52
|
+
await expect(
|
|
53
|
+
canVerifiedAccountHostMeeting('actor', {
|
|
54
|
+
email: 'host@example.com',
|
|
55
|
+
email_confirmed_at: '2026-01-01',
|
|
56
|
+
})
|
|
57
|
+
).rejects.toThrow('Unavailable');
|
|
58
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
import { canCreateOnlineMeeting } from './meet-creation-policy';
|
|
3
|
+
import { getHostMeetingTier } from './meet-duration';
|
|
4
|
+
|
|
5
|
+
/** Pass only the current auth.admin.getUserById result, never handoff/profile metadata. */
|
|
6
|
+
export async function canVerifiedAccountHostMeeting(
|
|
7
|
+
accountId: string,
|
|
8
|
+
identity:
|
|
9
|
+
| { email?: string | null; email_confirmed_at?: string | null }
|
|
10
|
+
| null
|
|
11
|
+
| undefined
|
|
12
|
+
) {
|
|
13
|
+
if (!identity?.email_confirmed_at || !identity.email) return false;
|
|
14
|
+
if (canCreateOnlineMeeting(identity.email)) return true;
|
|
15
|
+
return canCreateOnlineMeeting(
|
|
16
|
+
identity.email,
|
|
17
|
+
await getHostMeetingTier(accountId)
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
MeetingInvitationInputSchema,
|
|
4
|
+
normalizeGoogleResponse,
|
|
5
|
+
normalizeMicrosoftResponse,
|
|
6
|
+
} from './meeting-invitations';
|
|
7
|
+
|
|
8
|
+
describe('meeting invitation contract', () => {
|
|
9
|
+
it('normalizes internal and external guests without accepting response spoofing', () => {
|
|
10
|
+
const invitation = MeetingInvitationInputSchema.parse({
|
|
11
|
+
guests: [
|
|
12
|
+
{ email: 'NAME@TUTURUUU.COM', response: 'accepted' },
|
|
13
|
+
{ email: 'friend@gmail.com', optional: true },
|
|
14
|
+
],
|
|
15
|
+
timeZone: 'Asia/Ho_Chi_Minh',
|
|
16
|
+
});
|
|
17
|
+
expect(invitation.guests[0]).toEqual({ email: 'name@tuturuuu.com' });
|
|
18
|
+
expect(invitation.guests[1]?.optional).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
it.each([
|
|
21
|
+
{ guests: [], timeZone: 'UTC' },
|
|
22
|
+
{ guests: [{ email: 'invalid' }], timeZone: 'UTC' },
|
|
23
|
+
{ guests: [{ email: 'a@example.com' }], timeZone: 'fake/zone' },
|
|
24
|
+
{
|
|
25
|
+
guests: [{ email: 'a@example.com' }, { email: 'A@EXAMPLE.COM' }],
|
|
26
|
+
timeZone: 'UTC',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
guests: Array.from({ length: 101 }, (_, i) => ({
|
|
30
|
+
email: `${i}@example.com`,
|
|
31
|
+
})),
|
|
32
|
+
timeZone: 'UTC',
|
|
33
|
+
},
|
|
34
|
+
])('rejects invalid or ambiguous invitation input', (input) => {
|
|
35
|
+
expect(MeetingInvitationInputSchema.safeParse(input).success).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
it.each(['accepted', 'declined', 'tentative', 'needsAction'])(
|
|
38
|
+
'preserves Google %s',
|
|
39
|
+
(value) => {
|
|
40
|
+
expect(normalizeGoogleResponse(value)).toBe(value);
|
|
41
|
+
}
|
|
42
|
+
);
|
|
43
|
+
it.each([
|
|
44
|
+
['accepted', 'accepted'],
|
|
45
|
+
['declined', 'declined'],
|
|
46
|
+
['tentativelyAccepted', 'tentative'],
|
|
47
|
+
['notResponded', 'needsAction'],
|
|
48
|
+
['none', 'needsAction'],
|
|
49
|
+
['unexpected', 'needsAction'],
|
|
50
|
+
])('normalizes Microsoft %s', (value, expected) => {
|
|
51
|
+
expect(normalizeMicrosoftResponse(value)).toBe(expected);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('untrusted invitation input boundaries', () => {
|
|
56
|
+
it.each([
|
|
57
|
+
null,
|
|
58
|
+
undefined,
|
|
59
|
+
'accepted',
|
|
60
|
+
[],
|
|
61
|
+
{ guests: [{ email: 'a@example.com', optional: 'yes' }], timeZone: 'UTC' },
|
|
62
|
+
{
|
|
63
|
+
guests: [{ email: 'a@example.com', name: 'a'.repeat(201) }],
|
|
64
|
+
timeZone: 'UTC',
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
guests: [{ email: 'a@example.com\r\nBcc: victim@example.com' }],
|
|
68
|
+
timeZone: 'UTC',
|
|
69
|
+
},
|
|
70
|
+
{ guests: [{ email: 'a@example.com' }], timeZone: null },
|
|
71
|
+
])('rejects malformed input without throwing from safeParse', (input) => {
|
|
72
|
+
expect(MeetingInvitationInputSchema.safeParse(input).success).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('accepts the maximum guest count and strips organizer-controlled provider state', () => {
|
|
76
|
+
const result = MeetingInvitationInputSchema.parse({
|
|
77
|
+
guests: Array.from({ length: 100 }, (_, i) => ({
|
|
78
|
+
email: `guest${i}@example.com`,
|
|
79
|
+
self: true,
|
|
80
|
+
response: 'accepted',
|
|
81
|
+
})),
|
|
82
|
+
timeZone: 'UTC',
|
|
83
|
+
organizer: { email: 'spoof@example.com' },
|
|
84
|
+
cancelled: true,
|
|
85
|
+
});
|
|
86
|
+
expect(result.guests).toHaveLength(100);
|
|
87
|
+
expect(result).not.toHaveProperty('organizer');
|
|
88
|
+
expect(result).not.toHaveProperty('cancelled');
|
|
89
|
+
for (const guest of result.guests) {
|
|
90
|
+
expect(guest).not.toHaveProperty('self');
|
|
91
|
+
expect(guest).not.toHaveProperty('response');
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it.each([null, undefined, {}, [], 0, 'ACCEPTED', '<script>'])(
|
|
96
|
+
'treats unknown provider responses as unanswered',
|
|
97
|
+
(value) => {
|
|
98
|
+
expect(normalizeGoogleResponse(value)).toBe('needsAction');
|
|
99
|
+
expect(normalizeMicrosoftResponse(value)).toBe('needsAction');
|
|
100
|
+
}
|
|
101
|
+
);
|
|
102
|
+
});
|