@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.
- package/CHANGELOG.md +55 -0
- package/package.json +6 -4
- 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__/calendar-provider-readonly.test.ts +45 -0
- package/src/__tests__/calendar-recurrence-edit.test.ts +140 -0
- package/src/__tests__/calendar-recurrence.test.ts +479 -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__/permissions.test.tsx +21 -0
- package/src/__tests__/request-workspace.test.ts +63 -0
- package/src/__tests__/tutoring-teacher-hours.test.ts +274 -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/calendar-provider-readonly.ts +49 -0
- package/src/calendar-recurrence-edit.ts +148 -0
- package/src/calendar-recurrence-expansion.ts +131 -0
- package/src/calendar-recurrence-local.ts +45 -0
- package/src/calendar-recurrence-pattern.ts +54 -0
- package/src/calendar-recurrence-providers.ts +361 -0
- package/src/calendar-recurrence-validation.ts +180 -0
- package/src/calendar-recurrence.ts +5 -0
- package/src/calendar-utils.ts +41 -3
- package/src/common/metadata.test.ts +0 -6
- package/src/constants.ts +3 -0
- package/src/deployment-vault-permissions.tsx +26 -0
- package/src/desktop-signing-profiles.ts +20 -0
- package/src/google-calendar-import-fence.test.ts +182 -0
- package/src/google-calendar-import-fence.ts +166 -0
- package/src/next-config.ts +8 -1
- package/src/offline-download-guard.ts +160 -0
- package/src/permissions.tsx +2 -10
- 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/request-workspace.ts +29 -0
- package/src/seo-policy.json +196 -0
- package/src/seo.test.ts +168 -0
- package/src/seo.ts +49 -0
- package/src/tutoring-teacher-hours.ts +220 -0
- package/src/username-policy.json +87 -0
- package/src/username-policy.ts +18 -0
- package/vitest.config.ts +7 -0
package/src/seo.test.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { tryToParsePath } from 'next/dist/lib/try-to-parse-path';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { createTuturuuuNextConfig } from './next-config';
|
|
6
|
+
import { APP_SEO_POLICY, createSeoHeaders, type SeoApp } from './seo';
|
|
7
|
+
|
|
8
|
+
function blocked(
|
|
9
|
+
app: SeoApp,
|
|
10
|
+
pathname: string,
|
|
11
|
+
patterns?: string[],
|
|
12
|
+
preview = false
|
|
13
|
+
) {
|
|
14
|
+
return createSeoHeaders(app, patterns, {
|
|
15
|
+
VERCEL_ENV: preview ? 'preview' : 'production',
|
|
16
|
+
}).some((rule) => {
|
|
17
|
+
const parsed = tryToParsePath(rule.source);
|
|
18
|
+
expect(parsed.error, rule.source).toBeUndefined();
|
|
19
|
+
return new RegExp(parsed.regexStr ?? '').test(pathname);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('app indexing boundaries', () => {
|
|
24
|
+
it.each(Object.keys(APP_SEO_POLICY) as SeoApp[])(
|
|
25
|
+
'protects auth and API paths in %s',
|
|
26
|
+
(app) => {
|
|
27
|
+
for (const path of [
|
|
28
|
+
'/login',
|
|
29
|
+
'/vi/login',
|
|
30
|
+
'/en/verify-token',
|
|
31
|
+
'/api/v1/users',
|
|
32
|
+
'/dashboard',
|
|
33
|
+
'/invite/token',
|
|
34
|
+
]) {
|
|
35
|
+
expect(blocked(app, path), `${app}: ${path}`).toBe(true);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
it('keeps private apps, shared task links, meeting codes and embeds unindexed', () => {
|
|
41
|
+
for (const app of [
|
|
42
|
+
'tasks',
|
|
43
|
+
'meet',
|
|
44
|
+
'parley',
|
|
45
|
+
'chat',
|
|
46
|
+
'ai',
|
|
47
|
+
'infrastructure',
|
|
48
|
+
] as const) {
|
|
49
|
+
for (const path of [
|
|
50
|
+
'/',
|
|
51
|
+
'/personal',
|
|
52
|
+
'/vi/workspace-id',
|
|
53
|
+
'/shared/task/secret',
|
|
54
|
+
'/r/secret',
|
|
55
|
+
]) {
|
|
56
|
+
expect(blocked(app, path), `${app}: ${path}`).toBe(true);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
expect(blocked('forms', '/embed/share-code')).toBe(true);
|
|
60
|
+
expect(blocked('forms', '/')).toBe(false);
|
|
61
|
+
expect(blocked('forms', '/f/share-code')).toBe(false);
|
|
62
|
+
expect(blocked('forms', '/f/share-code/responses')).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('allows public shops and products but protects buyer transactions', () => {
|
|
66
|
+
for (const prefix of ['', '/vi', '/en']) {
|
|
67
|
+
expect(blocked('storefront', `${prefix}/my-store`)).toBe(false);
|
|
68
|
+
expect(blocked('storefront', `${prefix}/my-store/products/item`)).toBe(
|
|
69
|
+
false
|
|
70
|
+
);
|
|
71
|
+
for (const suffix of [
|
|
72
|
+
'cart',
|
|
73
|
+
'checkout',
|
|
74
|
+
'checkout/success',
|
|
75
|
+
'orders',
|
|
76
|
+
'orders/bearer-token',
|
|
77
|
+
]) {
|
|
78
|
+
expect(blocked('storefront', `${prefix}/my-store/${suffix}`)).toBe(
|
|
79
|
+
true
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('limits public education and tool surfaces to their declared pages', () => {
|
|
86
|
+
expect(blocked('learn', '/')).toBe(false);
|
|
87
|
+
expect(blocked('learn', '/personal/reports')).toBe(true);
|
|
88
|
+
expect(blocked('teach', '/personal/attendance')).toBe(true);
|
|
89
|
+
expect(blocked('tools', '/qr')).toBe(false);
|
|
90
|
+
expect(blocked('tools', '/qr/unknown')).toBe(true);
|
|
91
|
+
expect(blocked('nova', '/vi/learn/basic-techniques')).toBe(false);
|
|
92
|
+
expect(blocked('nova', '/vi/challenges')).toBe(true);
|
|
93
|
+
expect(blocked('git', '/-/personal/repositories')).toBe(true);
|
|
94
|
+
expect(blocked('git', '/tutur3u/platform/issues')).toBe(false);
|
|
95
|
+
expect(blocked('lettin', '/worlds/published-world')).toBe(false);
|
|
96
|
+
expect(blocked('lettin', '/personal/wiki/world')).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('uses the generated platform allowlist and matches exact route boundaries', () => {
|
|
100
|
+
const paths: string[] = JSON.parse(
|
|
101
|
+
readFileSync(
|
|
102
|
+
resolve(
|
|
103
|
+
__dirname,
|
|
104
|
+
'../../../apps/web/src/lib/seo/public-routes.generated.json'
|
|
105
|
+
),
|
|
106
|
+
'utf8'
|
|
107
|
+
)
|
|
108
|
+
);
|
|
109
|
+
const patterns = [...paths.map((path) => path.slice(1)), 'changelog/[^/]+'];
|
|
110
|
+
for (const path of paths) {
|
|
111
|
+
expect(blocked('web', path, patterns), path).toBe(false);
|
|
112
|
+
expect(blocked('web', `/vi${path === '/' ? '' : path}`, patterns)).toBe(
|
|
113
|
+
false
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
for (const path of [
|
|
117
|
+
'/personal/tasks',
|
|
118
|
+
'/workspace-id',
|
|
119
|
+
'/documents/private-id',
|
|
120
|
+
'/share/document/secret',
|
|
121
|
+
'/ai/chats/private',
|
|
122
|
+
'/pricing/unknown',
|
|
123
|
+
]) {
|
|
124
|
+
expect(blocked('web', path, patterns), path).toBe(true);
|
|
125
|
+
}
|
|
126
|
+
expect(blocked('web', '/changelog/released', patterns)).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('noindexes all preview URLs and preserves the policy after app headers', async () => {
|
|
130
|
+
expect(blocked('tools', '/qr', undefined, true)).toBe(true);
|
|
131
|
+
const config = createTuturuuuNextConfig({
|
|
132
|
+
seoApp: 'learn',
|
|
133
|
+
headers: async () => [
|
|
134
|
+
{
|
|
135
|
+
source: '/login',
|
|
136
|
+
headers: [{ key: 'X-Robots-Tag', value: 'index' }],
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
});
|
|
140
|
+
const rules = await config.headers?.();
|
|
141
|
+
expect(rules?.at(-1)?.headers[0]?.value).toBe(
|
|
142
|
+
'noindex, nofollow, nosnippet'
|
|
143
|
+
);
|
|
144
|
+
expect(config).not.toHaveProperty('seoApp');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('registers every Next app and prevents static/metadata route conflicts', () => {
|
|
148
|
+
const root = resolve(__dirname, '../../..');
|
|
149
|
+
for (const app of Object.keys(APP_SEO_POLICY)) {
|
|
150
|
+
const config = readFileSync(
|
|
151
|
+
resolve(root, `apps/${app}/next.config.ts`),
|
|
152
|
+
'utf8'
|
|
153
|
+
);
|
|
154
|
+
expect(config).toContain(`seoApp: '${app}'`);
|
|
155
|
+
for (const file of ['robots', 'sitemap']) {
|
|
156
|
+
expect(
|
|
157
|
+
existsSync(resolve(root, `apps/${app}/src/app/${file}.ts`)) &&
|
|
158
|
+
existsSync(
|
|
159
|
+
resolve(
|
|
160
|
+
root,
|
|
161
|
+
`apps/${app}/public/${file === 'robots' ? 'robots.txt' : 'sitemap.xml'}`
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
).toBe(false);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
});
|
package/src/seo.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import policy from './seo-policy.json';
|
|
2
|
+
|
|
3
|
+
export type SeoApp = keyof typeof policy;
|
|
4
|
+
export const APP_SEO_POLICY = policy;
|
|
5
|
+
export const PRIVATE_ROBOTS_HEADER = 'noindex, nofollow, nosnippet';
|
|
6
|
+
|
|
7
|
+
// These remain private even where a public dynamic slug pattern could match.
|
|
8
|
+
const PRIVATE_PATH_PATTERN =
|
|
9
|
+
'(?:api|auth|login|logout|signup|verify-token|auth-error|add-account|dashboard|onboarding|invite|shared|share|embed|orders|cart|checkout|account|no-access|access-denied|not-whitelisted|not-available|-)(?:/|$)';
|
|
10
|
+
|
|
11
|
+
function escapeRegex(value: string) {
|
|
12
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Next path-to-regexp fragments; every public pattern must match a whole URL. */
|
|
16
|
+
export function createSeoHeaders(
|
|
17
|
+
app: SeoApp,
|
|
18
|
+
publicPathPatterns?: readonly string[],
|
|
19
|
+
env: Record<string, string | undefined> = process.env
|
|
20
|
+
) {
|
|
21
|
+
const appPolicy = policy[app];
|
|
22
|
+
const publicPatterns = publicPathPatterns ?? [
|
|
23
|
+
...appPolicy.publicPaths.map(escapeRegex),
|
|
24
|
+
...appPolicy.publicPatterns,
|
|
25
|
+
];
|
|
26
|
+
const isPreview =
|
|
27
|
+
env.VERCEL_ENV === 'preview' ||
|
|
28
|
+
env.VERCEL_ENV === 'development' ||
|
|
29
|
+
env.NODE_ENV === 'development' ||
|
|
30
|
+
env.TUTURUUU_NOINDEX === '1';
|
|
31
|
+
const publicPattern = `(?:${publicPatterns.join('|')})/?$`;
|
|
32
|
+
const localePrefix = '(?:(?:en|vi)/)?';
|
|
33
|
+
|
|
34
|
+
return [
|
|
35
|
+
{
|
|
36
|
+
// A header reaches redirects, errors, API responses and non-HTML files.
|
|
37
|
+
// Preview builds and apps with no explicit public content fail closed.
|
|
38
|
+
source:
|
|
39
|
+
isPreview || publicPatterns.length === 0
|
|
40
|
+
? '/:path*'
|
|
41
|
+
: `/:path((?!(?:(?:en|vi)(?:/|$))?${publicPattern}).*)`,
|
|
42
|
+
headers: [{ key: 'X-Robots-Tag', value: PRIVATE_ROBOTS_HEADER }],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
source: `/:path(${localePrefix}${PRIVATE_PATH_PATTERN}.*)`,
|
|
46
|
+
headers: [{ key: 'X-Robots-Tag', value: PRIVATE_ROBOTS_HEADER }],
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { Temporal } from '@js-temporal/polyfill';
|
|
2
|
+
|
|
3
|
+
export type Weekday = 1 | 2 | 3 | 4 | 5 | 6 | 7;
|
|
4
|
+
export type HoursInterval = Readonly<{ start: string; end: string }>;
|
|
5
|
+
/** Missing weekday means unknown for staff defaults and inheritance for overrides. */
|
|
6
|
+
export type HoursWeek = Partial<Record<Weekday, readonly HoursInterval[]>>;
|
|
7
|
+
/** Opaque storage receipt. Never parse through Number or Date. */
|
|
8
|
+
export type HoursRevision = string;
|
|
9
|
+
export type ConfirmedHoursFrame = Readonly<{
|
|
10
|
+
timeZone: string;
|
|
11
|
+
confirmed: true;
|
|
12
|
+
}>;
|
|
13
|
+
export type StaffHours = Readonly<{
|
|
14
|
+
revision: HoursRevision;
|
|
15
|
+
frame: ConfirmedHoursFrame;
|
|
16
|
+
week: HoursWeek;
|
|
17
|
+
}>;
|
|
18
|
+
export type TeacherHoursOverride = Readonly<{
|
|
19
|
+
revision: HoursRevision;
|
|
20
|
+
week: HoursWeek;
|
|
21
|
+
}>;
|
|
22
|
+
export type EffectiveHoursDay = Readonly<{
|
|
23
|
+
day: Weekday;
|
|
24
|
+
source: 'default' | 'override' | 'unknown';
|
|
25
|
+
state: 'hours' | 'closed' | 'unknown';
|
|
26
|
+
intervals: readonly HoursInterval[];
|
|
27
|
+
}>;
|
|
28
|
+
const weekdays: readonly Weekday[] = [1, 2, 3, 4, 5, 6, 7];
|
|
29
|
+
|
|
30
|
+
function minute(value: string, end = false): number {
|
|
31
|
+
if (end && value === '24:00') return 1440;
|
|
32
|
+
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) {
|
|
33
|
+
throw new Error('Invalid teacher-hours clock');
|
|
34
|
+
}
|
|
35
|
+
const [hour, minutes] = value.split(':').map(Number);
|
|
36
|
+
return hour! * 60 + minutes!;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function validateHoursRevision(revision: HoursRevision): void {
|
|
40
|
+
if (
|
|
41
|
+
typeof revision !== 'string' ||
|
|
42
|
+
!revision.trim() ||
|
|
43
|
+
revision.length > 512
|
|
44
|
+
) {
|
|
45
|
+
throw new Error('Invalid teacher-hours revision');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function validateHoursFrame(frame: ConfirmedHoursFrame): void {
|
|
50
|
+
if (
|
|
51
|
+
frame?.confirmed !== true ||
|
|
52
|
+
typeof frame.timeZone !== 'string' ||
|
|
53
|
+
frame.timeZone === 'auto' ||
|
|
54
|
+
/^[+-]/.test(frame.timeZone)
|
|
55
|
+
) {
|
|
56
|
+
throw new Error('Teacher-hours timezone must be explicitly confirmed');
|
|
57
|
+
}
|
|
58
|
+
// Intl rejects non-IANA identifiers; UTC and recognized IANA aliases are valid.
|
|
59
|
+
new Intl.DateTimeFormat('en', { timeZone: frame.timeZone }).format(0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function validateHoursWeek(week: HoursWeek): void {
|
|
63
|
+
if (!week || typeof week !== 'object' || Array.isArray(week)) {
|
|
64
|
+
throw new Error('Invalid teacher-hours week');
|
|
65
|
+
}
|
|
66
|
+
for (const [key, intervals] of Object.entries(week)) {
|
|
67
|
+
if (
|
|
68
|
+
!/^[1-7]$/.test(key) ||
|
|
69
|
+
!Array.isArray(intervals) ||
|
|
70
|
+
intervals.length > 24
|
|
71
|
+
) {
|
|
72
|
+
throw new Error('Invalid teacher-hours weekday');
|
|
73
|
+
}
|
|
74
|
+
let previousEnd = -1;
|
|
75
|
+
for (const interval of intervals) {
|
|
76
|
+
const start = minute(interval.start);
|
|
77
|
+
const end = minute(interval.end, true);
|
|
78
|
+
if (end <= start || start < previousEnd) {
|
|
79
|
+
throw new Error('Teacher-hours intervals must be sorted and disjoint');
|
|
80
|
+
}
|
|
81
|
+
previousEnd = end;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function resolveTeacherHours(
|
|
87
|
+
defaults: StaffHours | null,
|
|
88
|
+
override: TeacherHoursOverride | null
|
|
89
|
+
): readonly EffectiveHoursDay[] {
|
|
90
|
+
if (defaults) {
|
|
91
|
+
validateHoursRevision(defaults.revision);
|
|
92
|
+
validateHoursFrame(defaults.frame);
|
|
93
|
+
validateHoursWeek(defaults.week);
|
|
94
|
+
}
|
|
95
|
+
if (override) {
|
|
96
|
+
validateHoursRevision(override.revision);
|
|
97
|
+
validateHoursWeek(override.week);
|
|
98
|
+
}
|
|
99
|
+
return weekdays.map((day) => {
|
|
100
|
+
const own = override?.week[day];
|
|
101
|
+
const inherited = defaults?.week[day];
|
|
102
|
+
// An override cannot establish an unconfirmed/missing staff timezone frame.
|
|
103
|
+
const intervals = defaults ? (own ?? inherited) : undefined;
|
|
104
|
+
return {
|
|
105
|
+
day,
|
|
106
|
+
source:
|
|
107
|
+
intervals === undefined ? 'unknown' : own ? 'override' : 'default',
|
|
108
|
+
state:
|
|
109
|
+
intervals === undefined
|
|
110
|
+
? 'unknown'
|
|
111
|
+
: intervals.length
|
|
112
|
+
? 'hours'
|
|
113
|
+
: 'closed',
|
|
114
|
+
intervals: intervals?.map((interval) => ({ ...interval })) ?? [],
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Pure receipt validation, not a database CAS or authorization implementation. */
|
|
120
|
+
export function validateHoursWriteReceipt(
|
|
121
|
+
expectedRevision: HoursRevision | null,
|
|
122
|
+
returnedRevision: HoursRevision
|
|
123
|
+
): void {
|
|
124
|
+
if (expectedRevision !== null) validateHoursRevision(expectedRevision);
|
|
125
|
+
validateHoursRevision(returnedRevision);
|
|
126
|
+
if (expectedRevision === returnedRevision) {
|
|
127
|
+
throw new Error('Teacher-hours write did not advance its revision');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Reset is a revisioned empty override, never deletion back to an absent row. */
|
|
132
|
+
export function resetTeacherHoursOverride(
|
|
133
|
+
expectedRevision: HoursRevision | null,
|
|
134
|
+
returnedRevision: HoursRevision
|
|
135
|
+
): TeacherHoursOverride {
|
|
136
|
+
validateHoursWriteReceipt(expectedRevision, returnedRevision);
|
|
137
|
+
return { revision: returnedRevision, week: {} };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export type HoursSlot = Readonly<{
|
|
141
|
+
date: string;
|
|
142
|
+
start: string;
|
|
143
|
+
endDate: string;
|
|
144
|
+
end: string;
|
|
145
|
+
}>;
|
|
146
|
+
export type HoursSlotDecision =
|
|
147
|
+
| Readonly<{ state: 'allowed'; startInstant: string; endInstant: string }>
|
|
148
|
+
| Readonly<{ state: 'closed' | 'unknown' | 'invalid' }>;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Staff-clock slot only: callers must convert other source frames explicitly.
|
|
152
|
+
* Reject DST gaps/folds and transition-crossing slots rather than choosing offsets.
|
|
153
|
+
* Overnight hours are represented explicitly on both weekdays; no inferred widening.
|
|
154
|
+
*/
|
|
155
|
+
export function assessTeacherHoursSlot(
|
|
156
|
+
defaults: StaffHours | null,
|
|
157
|
+
override: TeacherHoursOverride | null,
|
|
158
|
+
slot: HoursSlot
|
|
159
|
+
): HoursSlotDecision {
|
|
160
|
+
if (!defaults) return { state: 'unknown' };
|
|
161
|
+
try {
|
|
162
|
+
const week = resolveTeacherHours(defaults, override);
|
|
163
|
+
if (
|
|
164
|
+
!/^\d{4}-\d{2}-\d{2}$/.test(slot.date) ||
|
|
165
|
+
!/^\d{4}-\d{2}-\d{2}$/.test(slot.endDate)
|
|
166
|
+
) {
|
|
167
|
+
return { state: 'invalid' };
|
|
168
|
+
}
|
|
169
|
+
minute(slot.start);
|
|
170
|
+
minute(slot.end);
|
|
171
|
+
const start = Temporal.PlainDateTime.from(`${slot.date}T${slot.start}`);
|
|
172
|
+
const end = Temporal.PlainDateTime.from(`${slot.endDate}T${slot.end}`);
|
|
173
|
+
const civilMinutes = start.until(end, { largestUnit: 'minutes' }).minutes;
|
|
174
|
+
if (civilMinutes <= 0 || civilMinutes > 7 * 1440)
|
|
175
|
+
return { state: 'invalid' };
|
|
176
|
+
const zone = defaults.frame.timeZone;
|
|
177
|
+
const startZoned = start.toZonedDateTime(zone, {
|
|
178
|
+
disambiguation: 'reject',
|
|
179
|
+
});
|
|
180
|
+
const endZoned = end.toZonedDateTime(zone, { disambiguation: 'reject' });
|
|
181
|
+
if (
|
|
182
|
+
endZoned.epochMilliseconds - startZoned.epochMilliseconds !==
|
|
183
|
+
civilMinutes * 60_000
|
|
184
|
+
) {
|
|
185
|
+
return { state: 'invalid' };
|
|
186
|
+
}
|
|
187
|
+
let date = start.toPlainDate();
|
|
188
|
+
let unknown = false;
|
|
189
|
+
let closed = false;
|
|
190
|
+
while (Temporal.PlainDate.compare(date, end.toPlainDate()) <= 0) {
|
|
191
|
+
const sameStart =
|
|
192
|
+
Temporal.PlainDate.compare(date, start.toPlainDate()) === 0;
|
|
193
|
+
const sameEnd = Temporal.PlainDate.compare(date, end.toPlainDate()) === 0;
|
|
194
|
+
const from = sameStart ? minute(slot.start) : 0;
|
|
195
|
+
const to = sameEnd ? minute(slot.end) : 1440;
|
|
196
|
+
if (from < to) {
|
|
197
|
+
const day = week[date.dayOfWeek - 1]!;
|
|
198
|
+
if (day.state === 'unknown') unknown = true;
|
|
199
|
+
else {
|
|
200
|
+
let covered = from;
|
|
201
|
+
for (const interval of day.intervals) {
|
|
202
|
+
if (minute(interval.start) > covered) break;
|
|
203
|
+
covered = Math.max(covered, minute(interval.end, true));
|
|
204
|
+
}
|
|
205
|
+
if (covered < to) closed = true;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
date = date.add({ days: 1 });
|
|
209
|
+
}
|
|
210
|
+
if (closed) return { state: 'closed' };
|
|
211
|
+
if (unknown) return { state: 'unknown' };
|
|
212
|
+
return {
|
|
213
|
+
state: 'allowed',
|
|
214
|
+
startInstant: startZoned.toInstant().toString(),
|
|
215
|
+
endInstant: endZoned.toInstant().toString(),
|
|
216
|
+
};
|
|
217
|
+
} catch {
|
|
218
|
+
return { state: 'invalid' };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
{
|
|
2
|
+
"common": [
|
|
3
|
+
"admin",
|
|
4
|
+
"administrator",
|
|
5
|
+
"anonymous",
|
|
6
|
+
"account",
|
|
7
|
+
"accounts",
|
|
8
|
+
"artist",
|
|
9
|
+
"artists",
|
|
10
|
+
"creator",
|
|
11
|
+
"creators",
|
|
12
|
+
"dashboard",
|
|
13
|
+
"default",
|
|
14
|
+
"everyone",
|
|
15
|
+
"example",
|
|
16
|
+
"guest",
|
|
17
|
+
"help",
|
|
18
|
+
"home",
|
|
19
|
+
"login",
|
|
20
|
+
"logout",
|
|
21
|
+
"moderator",
|
|
22
|
+
"moderation",
|
|
23
|
+
"official",
|
|
24
|
+
"owner",
|
|
25
|
+
"personal",
|
|
26
|
+
"privacy",
|
|
27
|
+
"profile",
|
|
28
|
+
"public",
|
|
29
|
+
"root",
|
|
30
|
+
"security",
|
|
31
|
+
"settings",
|
|
32
|
+
"staff",
|
|
33
|
+
"support",
|
|
34
|
+
"system",
|
|
35
|
+
"terms",
|
|
36
|
+
"test",
|
|
37
|
+
"testing",
|
|
38
|
+
"unknown",
|
|
39
|
+
"user",
|
|
40
|
+
"username",
|
|
41
|
+
"users",
|
|
42
|
+
"website",
|
|
43
|
+
"webmaster"
|
|
44
|
+
],
|
|
45
|
+
"brands": [
|
|
46
|
+
"adobe",
|
|
47
|
+
"amazon",
|
|
48
|
+
"android",
|
|
49
|
+
"apple",
|
|
50
|
+
"chatgpt",
|
|
51
|
+
"claude",
|
|
52
|
+
"cloudflare",
|
|
53
|
+
"discord",
|
|
54
|
+
"exocorpse",
|
|
55
|
+
"facebook",
|
|
56
|
+
"github",
|
|
57
|
+
"gmail",
|
|
58
|
+
"google",
|
|
59
|
+
"instagram",
|
|
60
|
+
"linkedin",
|
|
61
|
+
"meta",
|
|
62
|
+
"microsoft",
|
|
63
|
+
"netflix",
|
|
64
|
+
"nintendo",
|
|
65
|
+
"nvidia",
|
|
66
|
+
"openai",
|
|
67
|
+
"paypal",
|
|
68
|
+
"samsung",
|
|
69
|
+
"slack",
|
|
70
|
+
"snapchat",
|
|
71
|
+
"sony",
|
|
72
|
+
"spotify",
|
|
73
|
+
"stripe",
|
|
74
|
+
"telegram",
|
|
75
|
+
"tesla",
|
|
76
|
+
"tiktok",
|
|
77
|
+
"tulletin",
|
|
78
|
+
"tulettin",
|
|
79
|
+
"tuturuuu",
|
|
80
|
+
"twitch",
|
|
81
|
+
"twitter",
|
|
82
|
+
"vercel",
|
|
83
|
+
"whatsapp",
|
|
84
|
+
"windows",
|
|
85
|
+
"youtube"
|
|
86
|
+
]
|
|
87
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import reserved from './username-policy.json';
|
|
2
|
+
|
|
3
|
+
export const USERNAME_MIN_LENGTH = 5;
|
|
4
|
+
export const USERNAME_MAX_LENGTH = 32;
|
|
5
|
+
export const USERNAME_CHANGE_INTERVAL_DAYS = 14;
|
|
6
|
+
export const DISPLAY_NAME_CHANGE_LIMIT = 2;
|
|
7
|
+
export const DISPLAY_NAME_CHANGE_WINDOW_DAYS = 7;
|
|
8
|
+
const reservedPatterns = [...reserved.common, ...reserved.brands].map(
|
|
9
|
+
(brand) =>
|
|
10
|
+
new RegExp(`^(official)?${brand}(official|support|admin|team|[0-9]+)?$`)
|
|
11
|
+
);
|
|
12
|
+
export function isReservedUsername(value: string) {
|
|
13
|
+
const normalized = value.toLowerCase().replaceAll('_', '');
|
|
14
|
+
return reservedPatterns.some((pattern) => pattern.test(normalized));
|
|
15
|
+
}
|
|
16
|
+
export function isValidNewUsername(value: string) {
|
|
17
|
+
return /^[a-z0-9][a-z0-9_]{4,31}$/.test(value) && !isReservedUsername(value);
|
|
18
|
+
}
|
package/vitest.config.ts
CHANGED
|
@@ -16,6 +16,13 @@ export default defineConfig({
|
|
|
16
16
|
resolve: {
|
|
17
17
|
alias: [
|
|
18
18
|
{ find: '@', replacement: resolve(__dirname, './src') },
|
|
19
|
+
{
|
|
20
|
+
find: '@tuturuuu/types/primitives/playgrounds',
|
|
21
|
+
replacement: resolve(
|
|
22
|
+
__dirname,
|
|
23
|
+
'../types/src/primitives/playgrounds.ts'
|
|
24
|
+
),
|
|
25
|
+
},
|
|
19
26
|
{
|
|
20
27
|
find: '@tuturuuu/internal-api/tasks',
|
|
21
28
|
replacement: resolve(__dirname, '../internal-api/src/tasks.ts'),
|