mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
validation in some areas, maybe move this to schema later
This commit is contained in:
@@ -8,6 +8,11 @@ import {
|
||||
getAccount,
|
||||
getAccountByUsername,
|
||||
getAccountsByIds,
|
||||
isValidBio,
|
||||
isValidEmail,
|
||||
MAX_DISPLAY_NAME_LENGTH,
|
||||
MAX_USERNAME_LENGTH,
|
||||
nameRejection,
|
||||
searchAccounts,
|
||||
updateAccount,
|
||||
} from '@repo/domain'
|
||||
@@ -422,7 +427,7 @@ const app = new Hono<App>()
|
||||
requestBody: form(DisplayNameRequest, 'The new display name'),
|
||||
responses: {
|
||||
200: json(SuccessResponse, 'Updated'),
|
||||
400: { description: 'Empty display name (empty body)' },
|
||||
400: { description: 'Empty, over 15 characters, or non-alphanumeric (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
@@ -431,6 +436,14 @@ const app = new Hono<App>()
|
||||
if (id === null) return unauthorized(c)
|
||||
const displayName = (await formField(c, 'displayName')).trim()
|
||||
if (displayName === '') return c.body(null, 400)
|
||||
// Held to the same shape as the username — it's the name other players actually
|
||||
// see, so it can't be the place where spaces and punctuation get in. Refused with
|
||||
// an empty 400 like the empty case above: this route answers a bare
|
||||
// SuccessResponse, and giving it a body the client has never been sent is a
|
||||
// bigger change than the rule is worth.
|
||||
if (nameRejection(displayName, 'display name', MAX_DISPLAY_NAME_LENGTH) !== null) {
|
||||
return c.body(null, 400)
|
||||
}
|
||||
const account = await updateAccount(c.env.DB, id, { displayName })
|
||||
await pushAccountUpdate(c, account)
|
||||
return c.json({ success: true })
|
||||
@@ -446,9 +459,10 @@ const app = new Hono<App>()
|
||||
tags: ['Profile'],
|
||||
summary: 'Change username',
|
||||
description: [
|
||||
'Rejects a name taken by another account and requires a remaining change; on',
|
||||
'success the name is persisted and the counter decremented. Always HTTP 200 —',
|
||||
'failures carry a message in `error` (see the UsernameResult envelope).',
|
||||
'Letters and digits only, at most 50 characters. Rejects a name taken by another',
|
||||
'account and requires a remaining change; on success the name is persisted and',
|
||||
'the counter decremented. Always HTTP 200 — failures carry a message in `error`',
|
||||
'(see the UsernameResult envelope).',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(UsernameRequest, 'The desired username'),
|
||||
@@ -464,6 +478,12 @@ const app = new Hono<App>()
|
||||
const username = (await formField(c, 'username')).trim()
|
||||
if (username === '') return usernameResult(c, 'You must enter a username.')
|
||||
|
||||
// Shape before availability: a rejected name shouldn't cost a D1 read, and it
|
||||
// must never cost the account one of its rationed changes. The envelope carries
|
||||
// the sentence straight to the player.
|
||||
const rejection = nameRejection(username, 'username', MAX_USERNAME_LENGTH)
|
||||
if (rejection !== null) return usernameResult(c, rejection)
|
||||
|
||||
// Duplicate check first (case-insensitive); keeping your own name is allowed.
|
||||
const existing = await getAccountByUsername(c.env.DB, username)
|
||||
if (existing && existing.accountId !== id) {
|
||||
@@ -497,7 +517,7 @@ const app = new Hono<App>()
|
||||
requestBody: form(EmailRequest, 'The new email'),
|
||||
responses: {
|
||||
200: json(SuccessResponse, 'Updated'),
|
||||
400: { description: 'Email without an “@” (empty body)' },
|
||||
400: { description: 'Not a valid address, or over 255 characters (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
@@ -505,7 +525,7 @@ const app = new Hono<App>()
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const email = (await formField(c, 'email')).trim()
|
||||
if (!email.includes('@')) return c.body(null, 400)
|
||||
if (!isValidEmail(email)) return c.body(null, 400)
|
||||
await updateAccount(c.env.DB, id, { email })
|
||||
return c.json({ success: true })
|
||||
}
|
||||
@@ -605,11 +625,12 @@ const app = new Hono<App>()
|
||||
describeRoute({
|
||||
tags: ['Profile'],
|
||||
summary: 'Set bio',
|
||||
description: 'Free text; empty is allowed. Persisted and broadcast.',
|
||||
description: 'Free text up to 255 characters; empty is allowed. Persisted and broadcast.',
|
||||
security: AUTHED,
|
||||
requestBody: form(BioRequest, 'The new bio'),
|
||||
responses: {
|
||||
200: json(SuccessResponse, 'Updated'),
|
||||
400: { description: 'Bio over 255 characters (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
@@ -617,6 +638,9 @@ const app = new Hono<App>()
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const bio = await formField(c, 'bio')
|
||||
// Length only — a bio is free text by design (see the route summary). Refused
|
||||
// rather than truncated: silently storing half a sentence reads as data loss.
|
||||
if (!isValidBio(bio)) return c.body(null, 400)
|
||||
const account = await updateAccount(c.env.DB, id, { bio })
|
||||
await pushAccountUpdate(c, account)
|
||||
return c.json({ success: true })
|
||||
|
||||
@@ -124,15 +124,21 @@ export const CreateAccountRequest = z.object({
|
||||
|
||||
/** Single-string form bodies, one per profile mutation. */
|
||||
export const DisplayNameRequest = z.object({
|
||||
displayName: z.string().describe('Trimmed; empty is rejected (400)'),
|
||||
displayName: z
|
||||
.string()
|
||||
.describe('Trimmed; letters and digits only, max 15. Empty or invalid is rejected (400)'),
|
||||
})
|
||||
|
||||
export const UsernameRequest = z.object({
|
||||
username: z.string().describe('Trimmed; must be unique and changes must remain'),
|
||||
username: z
|
||||
.string()
|
||||
.describe(
|
||||
'Trimmed; letters and digits only, max 50. Must be unique and changes must remain'
|
||||
),
|
||||
})
|
||||
|
||||
export const EmailRequest = z.object({
|
||||
email: z.string().describe('Must contain "@"; otherwise 400'),
|
||||
email: z.string().describe('A deliverable-looking address, max 255; otherwise 400'),
|
||||
})
|
||||
|
||||
export const PhoneRequest = z.object({
|
||||
@@ -147,7 +153,9 @@ export const PronounsRequest = z.object({
|
||||
pronounFlags: z.string().describe('Integer string bitmask; non-numeric is 400'),
|
||||
})
|
||||
|
||||
export const BioRequest = z.object({ bio: z.string().describe('Free text; empty is allowed') })
|
||||
export const BioRequest = z.object({
|
||||
bio: z.string().describe('Free text, max 255; empty is allowed'),
|
||||
})
|
||||
|
||||
export const ProfileImageRequest = z.object({
|
||||
imageName: z.string().describe('Avatar object key; empty is rejected (400)'),
|
||||
|
||||
@@ -460,3 +460,150 @@ describe('auth-gated endpoints', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// The names a player chooses are alphanumeric and length-capped, by the same rule the
|
||||
// `rooms` worker applies (see `nameRejection` in @repo/domain). The three limits come
|
||||
// from the client's own input boxes rather than a round number, so anything stored is
|
||||
// something the game can render and re-edit.
|
||||
//
|
||||
// Server-generated names go around this deliberately — the seeded "Rec Room" account
|
||||
// above has a space in its display name, and dorms are called `@<username>'s Dorm`. The
|
||||
// check belongs at the request handler, not in the db helpers.
|
||||
describe('name, email and bio validation', () => {
|
||||
const authed = async (sub: string) => ({
|
||||
...(await bearer(sub)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
})
|
||||
|
||||
test('PUT /account/me/username refuses anything but letters and digits, max 50', async () => {
|
||||
const headers = await authed('8801')
|
||||
for (const username of ['has space', 'under_score', 'punct!', 'café', 'a'.repeat(51)]) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
|
||||
...form({ username }),
|
||||
headers,
|
||||
})
|
||||
// Still the envelope at HTTP 200, like every other refusal on this route.
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { success: boolean; error: string }
|
||||
expect(body.success, username).toBe(false)
|
||||
expect(body.error).toMatch(/letters and numbers|at most 50 characters/)
|
||||
}
|
||||
|
||||
// The rationed change must NOT be spent by a refusal: an account starts with one,
|
||||
// and burning it on a typo would leave the player stuck with a name they never had.
|
||||
const me = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('8801') })
|
||||
).json()) as { availableUsernameChanges: number }
|
||||
expect(me.availableUsernameChanges).toBe(1)
|
||||
|
||||
// 50 is the client's own cap, so a name that long has to be accepted.
|
||||
const ok = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
|
||||
...form({ username: 'a'.repeat(50) }),
|
||||
headers,
|
||||
})
|
||||
expect(((await ok.json()) as { success: boolean }).success).toBe(true)
|
||||
})
|
||||
|
||||
test('PUT /account/me/displayname refuses anything but letters and digits, max 15', async () => {
|
||||
const headers = await authed('8802')
|
||||
for (const displayName of ['has space', 'punct!', 'a'.repeat(16)]) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
|
||||
...form({ displayName }),
|
||||
headers,
|
||||
})
|
||||
// An empty 400, matching what this route already answers for an empty name —
|
||||
// it acks with a bare `{ success: true }` and has never sent the client a body
|
||||
// on failure.
|
||||
expect(res.status, displayName).toBe(400)
|
||||
}
|
||||
|
||||
// 15 is the client's box, so it must fit.
|
||||
const ok = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
|
||||
...form({ displayName: 'a'.repeat(15) }),
|
||||
headers,
|
||||
})
|
||||
expect(ok.status).toBe(200)
|
||||
})
|
||||
|
||||
// Syntax comes from the `isemail` package rather than a pattern written here — this is
|
||||
// a contact address nothing is ever sent to in order to prove it, so a hand-rolled
|
||||
// regex only buys more edge cases to get wrong. It enforces the RFC's own
|
||||
// 254-character maximum, which is why there's no separate length check.
|
||||
test('POST /account/me/email requires a syntactically valid address', async () => {
|
||||
const headers = await authed('8803')
|
||||
const bad = [
|
||||
'nope', // no @ at all — what this route used to be the only check for
|
||||
'@example.com', // nothing to deliver to
|
||||
'someone@', // no domain
|
||||
'someone@example.', // empty last label
|
||||
'two words@example.com', // whitespace
|
||||
`${'a'.repeat(250)}@example.com`, // past the RFC's 254
|
||||
]
|
||||
for (const email of bad) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/email`, {
|
||||
...form({ email }),
|
||||
method: 'POST',
|
||||
headers,
|
||||
})
|
||||
expect(res.status, email).toBe(400)
|
||||
}
|
||||
|
||||
// `someone@localhost` is in the ACCEPTED list on purpose: it's valid per the RFC,
|
||||
// and an undeliverable address costs nothing here.
|
||||
for (const email of [
|
||||
'someone@example.com',
|
||||
'first.last+tag@mail.example.co.uk',
|
||||
'someone@localhost',
|
||||
]) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/email`, {
|
||||
...form({ email }),
|
||||
method: 'POST',
|
||||
headers,
|
||||
})
|
||||
expect(res.status, email).toBe(200)
|
||||
}
|
||||
})
|
||||
|
||||
test('PUT /account/me/bio caps the stored text at 255 characters', async () => {
|
||||
const headers = await authed('8804')
|
||||
|
||||
const ok = await exports.default.fetch(`${ORIGIN}/account/me/bio`, {
|
||||
...form({ bio: 'b'.repeat(255) }),
|
||||
headers,
|
||||
})
|
||||
expect(ok.status).toBe(200)
|
||||
|
||||
// Refused rather than truncated — storing half a sentence reads as data loss.
|
||||
const tooLong = await exports.default.fetch(`${ORIGIN}/account/me/bio`, {
|
||||
...form({ bio: 'b'.repeat(256) }),
|
||||
headers,
|
||||
})
|
||||
expect(tooLong.status).toBe(400)
|
||||
|
||||
// The refusal changed nothing: the 255-character bio is still what's stored.
|
||||
const me = await exports.default.fetch(`${ORIGIN}/account/8804/bio`)
|
||||
expect(((await me.json()) as { bio: string }).bio).toBe('b'.repeat(255))
|
||||
})
|
||||
})
|
||||
|
||||
// Phone is deliberately NOT held to the name rule above: the client sends E.164
|
||||
// (`+15552223333`), so a letters-and-digits check would reject every real number by
|
||||
// eating the leading `+`. Pinned here because this route sits between two that DID just
|
||||
// get stricter, and the obvious next "cleanup" is to make it match them.
|
||||
test('POST /account/me/phone stores an E.164 number exactly as the client sends it', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/phone`, {
|
||||
...form({ phone: '+15552223333' }),
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('8805')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true })
|
||||
|
||||
// Read from the row: phone is stored but not surfaced by any DTO, so there's no
|
||||
// endpoint to check it through.
|
||||
const row = await env.DB.prepare(
|
||||
"SELECT json_extract(data, '$.phone') AS phone FROM account WHERE json_extract(data, '$.accountId') = 8805"
|
||||
).first<{ phone: string }>()
|
||||
// Verbatim — no normalising, no stripping of the +.
|
||||
expect(row?.phone).toBe('+15552223333')
|
||||
})
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
* relational table rather than a JSON blob.
|
||||
*/
|
||||
|
||||
import {
|
||||
glyphLength,
|
||||
MAX_EVENT_DESCRIPTION_LENGTH,
|
||||
MAX_EVENT_NAME_LENGTH,
|
||||
} from '@repo/domain'
|
||||
|
||||
/**
|
||||
* Schema DDL (mirror of migrations/0006_event.sql + 0007_event_attendee.sql, sans any
|
||||
* seed rows).
|
||||
@@ -251,6 +257,30 @@ function asInt(value: unknown): number | undefined {
|
||||
* clear the value. Timestamps are normalized here, so an unparseable one is dropped
|
||||
* rather than stored.
|
||||
*/
|
||||
/**
|
||||
* Why a parsed event body can't be stored, or `null` when it's fine.
|
||||
*
|
||||
* Length only. An event name is a title, not an identifier — "Building a Better Room
|
||||
* Using Trigonometry" is a real one — so the alphanumeric rule the account and room
|
||||
* names carry would be wrong here. Absent fields are skipped: an update posts only what
|
||||
* it changes, and create defaults a missing name rather than refusing it.
|
||||
*
|
||||
* The name is measured AFTER trimming, matching what create/update actually store.
|
||||
*/
|
||||
export function eventInputRejection(input: EventInput): string | null {
|
||||
const name = input.name?.trim()
|
||||
if (name !== undefined && glyphLength(name) > MAX_EVENT_NAME_LENGTH) {
|
||||
return `Event names can be at most ${MAX_EVENT_NAME_LENGTH} characters.`
|
||||
}
|
||||
if (
|
||||
input.description !== undefined &&
|
||||
glyphLength(input.description) > MAX_EVENT_DESCRIPTION_LENGTH
|
||||
) {
|
||||
return `Event descriptions can be at most ${MAX_EVENT_DESCRIPTION_LENGTH} characters.`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function parseEventBody(body: unknown): EventInput {
|
||||
const outer = (typeof body === 'object' && body !== null ? body : {}) as Record<string, unknown>
|
||||
const nested = outer.PlayerEvent
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getEventsByIds,
|
||||
getLiveEvents,
|
||||
isEventResponseType,
|
||||
eventInputRejection,
|
||||
parseEventBody,
|
||||
searchEvents,
|
||||
setEventResponse,
|
||||
@@ -327,6 +328,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
requestBody: jsonBody(PlayerEventRequest, 'The event to schedule'),
|
||||
responses: {
|
||||
200: json(PlayerEventResultDto, 'The created event'),
|
||||
400: { description: 'Name over 64 or description over 512 characters (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
@@ -334,7 +336,13 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const body = await c.req.json<unknown>().catch(() => ({}))
|
||||
const event = await createEvent(c.env.DB, id, parseEventBody(body))
|
||||
const input = parseEventBody(body)
|
||||
// The one thing this route isn't lenient about. Everything else here defaults a
|
||||
// missing or unusable field, but a name or description past the stored length
|
||||
// can't be defaulted into something sensible — and truncating a player's event
|
||||
// description silently is worse than refusing it.
|
||||
if (eventInputRejection(input) !== null) return c.body(null, 400)
|
||||
const event = await createEvent(c.env.DB, id, input)
|
||||
await notifyEventCreated(c, event)
|
||||
return c.json(toEventResult(event))
|
||||
}
|
||||
@@ -359,6 +367,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
requestBody: jsonBody(PlayerEventRequest, 'The fields to change'),
|
||||
responses: {
|
||||
200: json(PlayerEventResultDto, 'The updated event'),
|
||||
400: { description: 'Name over 64 or description over 512 characters (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: { description: 'Not the event’s creator (empty body)' },
|
||||
404: { description: 'No such event (empty body)' },
|
||||
@@ -373,7 +382,9 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
if (existing.CreatorPlayerId !== id) return c.body(null, 403)
|
||||
|
||||
const body = await c.req.json<unknown>().catch(() => ({}))
|
||||
const updated = await updateEvent(c.env.DB, eventId, parseEventBody(body))
|
||||
const input = parseEventBody(body)
|
||||
if (eventInputRejection(input) !== null) return c.body(null, 400)
|
||||
const updated = await updateEvent(c.env.DB, eventId, input)
|
||||
// updateEvent only returns null when the row vanished, which the read above rules out.
|
||||
return c.json(toEventResult(updated!))
|
||||
}
|
||||
|
||||
@@ -2420,6 +2420,44 @@ describe('player events', () => {
|
||||
expect(upcoming.StartTime).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
|
||||
})
|
||||
|
||||
// The one thing the event writes are strict about. Everything else here defaults a
|
||||
// missing or unusable field (a nameless event becomes "Untitled Event"), but a name or
|
||||
// description past the stored length can't be defaulted into anything sensible, and
|
||||
// truncating a player's description silently is worse than refusing the write.
|
||||
//
|
||||
// Deliberately length ONLY: an event name is a title, not an identifier — the fixture
|
||||
// above is called "Building a Better Room Using Trigonometry" — so the alphanumeric
|
||||
// rule that guards usernames and room names would be wrong here.
|
||||
test('POST /api/playerevents/v2 caps the name at 64 and the description at 512', async () => {
|
||||
expect((await post('/api/playerevents/v2', { Name: 'n'.repeat(65), RoomId: 3 })).status).toBe(
|
||||
400
|
||||
)
|
||||
expect((await post('/api/playerevents/v2', { Name: 'n'.repeat(64), RoomId: 3 })).status).toBe(
|
||||
200
|
||||
)
|
||||
|
||||
const withDescription = (description: string) =>
|
||||
post('/api/playerevents/v2', { Name: 'Described', RoomId: 3, Description: description })
|
||||
expect((await withDescription('d'.repeat(513))).status).toBe(400)
|
||||
expect((await withDescription('d'.repeat(512))).status).toBe(200)
|
||||
// Counted in code points, so an emoji costs one character rather than two.
|
||||
expect((await withDescription('🎉'.repeat(512))).status).toBe(200)
|
||||
|
||||
// Spaces and punctuation stay fine — this is a title, not an identifier.
|
||||
expect(
|
||||
(await post('/api/playerevents/v2', { Name: "Bob's Big Night (2)!", RoomId: 3 })).status
|
||||
).toBe(200)
|
||||
|
||||
// The update path enforces the same limits, and a refusal leaves the event alone.
|
||||
const event = await create({ Name: 'EditMe', RoomId: 3 })
|
||||
const tooLong = await post(`/api/playerevents/v2/${event.PlayerEventId}`, {
|
||||
Name: 'n'.repeat(65),
|
||||
})
|
||||
expect(tooLong.status).toBe(400)
|
||||
const after = await get(`/api/playerevents/v1/${event.PlayerEventId}`)
|
||||
expect(((await after.json()) as PlayerEvent).Name).toBe('EditMe')
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v2 answers the write envelope, not the bare event', async () => {
|
||||
const res = await post('/api/playerevents/v2', { Name: 'Enveloped', RoomId: 3 })
|
||||
const body = (await res.json()) as PlayerEventResult
|
||||
|
||||
@@ -2,6 +2,11 @@ import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import {
|
||||
glyphLength,
|
||||
MAX_CLUB_DESCRIPTION_LENGTH,
|
||||
MAX_CLUB_NAME_LENGTH,
|
||||
} from '@repo/domain'
|
||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
@@ -101,8 +106,6 @@ async function authedId(c: Context<App>): Promise<number | null> {
|
||||
*/
|
||||
const DEFAULT_MAX_CLUBS_PER_ACCOUNT = 10
|
||||
|
||||
/** Longest a club name may be (the reference's MaxNameLength). */
|
||||
const MAX_CLUB_NAME_LENGTH = 16
|
||||
|
||||
/**
|
||||
* The tiers `members/invite` may grant — the real member roles only. Creator (100) is
|
||||
@@ -665,6 +668,14 @@ const app = new Hono<App>()
|
||||
if ([...name].length > MAX_CLUB_NAME_LENGTH) {
|
||||
return clubError(c, `Club names can be at most ${MAX_CLUB_NAME_LENGTH} characters.`)
|
||||
}
|
||||
// Counted in code points like the name above, so an emoji-heavy description is
|
||||
// measured the way a player sees it rather than by UTF-16 units.
|
||||
if (glyphLength(description) > MAX_CLUB_DESCRIPTION_LENGTH) {
|
||||
return clubError(
|
||||
c,
|
||||
`Club descriptions can be at most ${MAX_CLUB_DESCRIPTION_LENGTH} characters.`
|
||||
)
|
||||
}
|
||||
// The per-account cap, checked after the cheap validations so a rejected name
|
||||
// costs no extra D1 read.
|
||||
const maxClubs = intVar(c.env.MAX_CLUBS_PER_ACCOUNT, DEFAULT_MAX_CLUBS_PER_ACCOUNT)
|
||||
@@ -778,9 +789,19 @@ const app = new Hono<App>()
|
||||
}
|
||||
}
|
||||
|
||||
// Same absent-means-unchanged rule as the name, so a club with no description
|
||||
// isn't forced to grow one just to be edited.
|
||||
const description = field('description') || undefined
|
||||
if (description !== undefined && glyphLength(description) > MAX_CLUB_DESCRIPTION_LENGTH) {
|
||||
return clubError(
|
||||
c,
|
||||
`Club descriptions can be at most ${MAX_CLUB_DESCRIPTION_LENGTH} characters.`
|
||||
)
|
||||
}
|
||||
|
||||
const updated = await updateClub(c.env.DB, clubId, {
|
||||
name,
|
||||
description: field('description') || undefined,
|
||||
description,
|
||||
category: field('category')?.trim() || undefined,
|
||||
visibility: parseVisibility(field('visibility')),
|
||||
joinability: parseJoinability(field('joinability')),
|
||||
|
||||
@@ -65,7 +65,7 @@ export const EmptyObject = z.object({})
|
||||
*/
|
||||
export const ClubDto = z.object({
|
||||
ClubId: z.int(),
|
||||
Name: z.string().describe('At most 16 characters; letters, digits and basic punctuation'),
|
||||
Name: z.string().describe('At most 40 characters; letters, digits and basic punctuation'),
|
||||
Description: z.string(),
|
||||
Category: z.string().describe('One of the /club/categoryTags values; defaults to Social'),
|
||||
Visibility: z.int().describe('ClubVisibility: 0 = Private, 1 = Public'),
|
||||
@@ -277,8 +277,8 @@ export const ChatDisabledResponse = z.boolean()
|
||||
export const CreateClubRequest = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.describe('Required; at most 16 characters, letters/digits/basic punctuation only'),
|
||||
description: z.string().optional(),
|
||||
.describe('Required; at most 40 characters, letters/digits/basic punctuation only'),
|
||||
description: z.string().optional().describe('At most 512 characters'),
|
||||
category: z.string().optional().describe('Defaults to Social when unset'),
|
||||
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
|
||||
joinability: z
|
||||
@@ -292,8 +292,11 @@ export const CreateClubRequest = z.object({
|
||||
|
||||
/** `PUT /club/:clubId/modifydetails` (and `/modify`) form body. */
|
||||
export const ModifyClubRequest = z.object({
|
||||
name: z.string().optional().describe('Empty means unchanged, not "clear it"'),
|
||||
description: z.string().optional().describe('Empty means unchanged'),
|
||||
name: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('At most 40 characters. Empty means unchanged, not "clear it"'),
|
||||
description: z.string().optional().describe('At most 512 characters. Empty means unchanged'),
|
||||
category: z.string().optional(),
|
||||
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
|
||||
joinability: z
|
||||
|
||||
@@ -241,9 +241,16 @@ describe('clubs endpoints', () => {
|
||||
expect(emoji.status).toBe(400)
|
||||
expect(await emoji.json()).toMatchObject({ success: false, value: null })
|
||||
|
||||
// Names cap at 16 characters.
|
||||
expect((await create({ name: 'a'.repeat(17) })).status).toBe(400)
|
||||
expect((await create({ name: 'a'.repeat(16) })).status).toBe(200)
|
||||
// Names cap at 40 characters.
|
||||
expect((await create({ name: 'a'.repeat(41) })).status).toBe(400)
|
||||
expect((await create({ name: 'a'.repeat(40) })).status).toBe(200)
|
||||
|
||||
// Descriptions cap at 512. Counted in code points, so an emoji-heavy one isn't
|
||||
// refused at half the length a player can see (the description has no charset rule
|
||||
// — only the name does).
|
||||
expect((await create({ name: 'DescTooLong', description: 'd'.repeat(513) })).status).toBe(400)
|
||||
expect((await create({ name: 'DescAtLimit', description: 'd'.repeat(512) })).status).toBe(200)
|
||||
expect((await create({ name: 'DescEmoji', description: '🎉'.repeat(512) })).status).toBe(200)
|
||||
|
||||
// Basic punctuation is allowed.
|
||||
expect((await create({ name: "Bob's Club (2)" })).status).toBe(200)
|
||||
|
||||
@@ -31,7 +31,9 @@ import {
|
||||
getSubRoomPermissions,
|
||||
getSubRoomSaves,
|
||||
getVisitedRooms,
|
||||
MAX_ROOM_NAME_LENGTH,
|
||||
modifySubRoom,
|
||||
nameRejection,
|
||||
publishSubRoomSave,
|
||||
removeCheer,
|
||||
removeFavorite,
|
||||
@@ -1071,6 +1073,9 @@ const app = new Hono<App>()
|
||||
const name = typeof raw === 'string' ? raw.trim() : ''
|
||||
|
||||
if (name === '') return roomEnvelope(c, null, 'You must enter a name for your room.')
|
||||
// Shape before availability, so a rejected name costs no D1 read.
|
||||
const badName = nameRejection(name, 'room name', MAX_ROOM_NAME_LENGTH)
|
||||
if (badName !== null) return roomEnvelope(c, null, badName)
|
||||
if (await getRoomByName(c.env.DB, name)) {
|
||||
return roomEnvelope(c, null, 'A room with that name already exists!')
|
||||
}
|
||||
@@ -1195,6 +1200,12 @@ const app = new Hono<App>()
|
||||
Error: 'You must enter a name for your room!',
|
||||
})
|
||||
}
|
||||
// Same ErrorId as the empty case — the client keys off it to mark the field, and
|
||||
// both are the name being unusable. The sentence is what tells them which.
|
||||
const badName = nameRejection(name, 'room name', MAX_ROOM_NAME_LENGTH)
|
||||
if (badName !== null) {
|
||||
return roomResult(c, { Success: false, ErrorId: 'Rooms.InvalidName', Error: badName })
|
||||
}
|
||||
|
||||
// Reject if a different room already uses this name (case-insensitive).
|
||||
const existing = await getRoomByName(c.env.DB, name)
|
||||
@@ -2075,6 +2086,10 @@ const app = new Hono<App>()
|
||||
Error: 'You must enter a name for your room!',
|
||||
})
|
||||
}
|
||||
const badName = nameRejection(name, 'subroom name', MAX_ROOM_NAME_LENGTH)
|
||||
if (badName !== null) {
|
||||
return roomResult(c, { Success: false, ErrorId: 'Rooms.InvalidName', Error: badName })
|
||||
}
|
||||
const maxPlayers =
|
||||
typeof body.maxPlayers === 'string' ? Number.parseInt(body.maxPlayers, 10) : Number.NaN
|
||||
|
||||
@@ -2383,6 +2398,8 @@ const app = new Hono<App>()
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
||||
if (name === '') return roomEnvelope(c, null, 'You must enter a name for your subroom!')
|
||||
const badName = nameRejection(name, 'subroom name', MAX_ROOM_NAME_LENGTH)
|
||||
if (badName !== null) return roomEnvelope(c, null, badName)
|
||||
|
||||
const result = await createSubRoom(c.env.DB, roomId, accountId, name)
|
||||
if (!result) return roomEnvelope(c, null, 'This room does not exist!')
|
||||
|
||||
@@ -2499,10 +2499,10 @@ describe('rooms endpoints', () => {
|
||||
await SELF.fetch(`${ORIGIN}/rooms/2/subrooms`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('1')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ name: 'to-delete' }).toString(),
|
||||
body: new URLSearchParams({ name: 'ToDelete' }).toString(),
|
||||
})
|
||||
).json()) as { value: { SubRooms: SubRoom[] } }
|
||||
const newId = created.value.SubRooms.find((s) => s.Name === 'to-delete')!.SubRoomId
|
||||
const newId = created.value.SubRooms.find((s) => s.Name === 'ToDelete')!.SubRoomId
|
||||
|
||||
// No token → 401. Not the owner → success:false. Unknown subroom → success:false.
|
||||
expect((await del(2, newId)).status).toBe(401)
|
||||
@@ -2656,3 +2656,81 @@ describe('rooms endpoints', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Room and subroom names are held to the same rule as usernames — letters and digits,
|
||||
// at most 32 (see `nameRejection` in @repo/domain). All four routes that take a
|
||||
// player-supplied name enforce it, and each keeps its OWN refusal shape: the create
|
||||
// paths answer the lowercase `{ success, error, value }` envelope, the two settings
|
||||
// routes answer `{ Success, ErrorId, Error }` with the same `Rooms.InvalidName` id they
|
||||
// already used for an empty name. The client keys off those, so the rule had to fit the
|
||||
// existing shapes rather than introduce a fifth one.
|
||||
//
|
||||
// Names the SERVER generates are exempt on purpose — a dorm is `@<username>'s Dorm`,
|
||||
// which this rule would reject. That's why the check lives in the handlers.
|
||||
describe('room name validation', () => {
|
||||
const bad = ['My Room', 'under_score', 'punct!', 'a'.repeat(33)]
|
||||
|
||||
const post = async (path: string, fields: Record<string, string>, sub: string) =>
|
||||
SELF.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(fields).toString(),
|
||||
})
|
||||
|
||||
const put = async (path: string, fields: Record<string, string>, sub: string) =>
|
||||
SELF.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(fields).toString(),
|
||||
})
|
||||
|
||||
it('refuses a bad name when a player clones a room into existence', async () => {
|
||||
for (const name of bad) {
|
||||
const res = await post('/rooms/2/clone', { name }, '1')
|
||||
const body = (await res.json()) as { success: boolean; error: string; value: unknown }
|
||||
expect(body.success, name).toBe(false)
|
||||
expect(body.error).toMatch(/letters and numbers|at most 32 characters/)
|
||||
expect(body.value).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a bad name on rename, with the id the client already handles', async () => {
|
||||
for (const name of bad) {
|
||||
const res = await put('/rooms/2/name', { name }, '1')
|
||||
const body = (await res.json()) as { Success: boolean; ErrorId: string; Error: string }
|
||||
expect(body.Success, name).toBe(false)
|
||||
expect(body.ErrorId).toBe('Rooms.InvalidName')
|
||||
expect(body.Error).toMatch(/letters and numbers|at most 32 characters/)
|
||||
}
|
||||
|
||||
// Unchanged: the refusals above never reached the write.
|
||||
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { Name: string }
|
||||
expect(room.Name).not.toMatch(/[^A-Za-z0-9]/)
|
||||
})
|
||||
|
||||
it('refuses a bad name when creating or modifying a subroom', async () => {
|
||||
for (const name of bad) {
|
||||
const created = await post('/rooms/2/subrooms', { name }, '1')
|
||||
const env1 = (await created.json()) as { success: boolean; error: string }
|
||||
expect(env1.success, name).toBe(false)
|
||||
expect(env1.error).toMatch(/letters and numbers|at most 32 characters/)
|
||||
|
||||
const modified = await put(
|
||||
'/rooms/2/subrooms/2/modify',
|
||||
{ name, accessibility: '1', maxPlayers: '20' },
|
||||
'1'
|
||||
)
|
||||
const res2 = (await modified.json()) as { Success: boolean; ErrorId: string }
|
||||
expect(res2.Success, name).toBe(false)
|
||||
expect(res2.ErrorId).toBe('Rooms.InvalidName')
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts a 32-character alphanumeric name', async () => {
|
||||
const name = 'a'.repeat(32)
|
||||
const res = await post('/rooms/2/subrooms', { name }, '1')
|
||||
const body = (await res.json()) as { success: boolean; value: { SubRooms: Array<{ Name: string }> } }
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.value.SubRooms.some((s) => s.Name === name)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
+23
-11
@@ -150,6 +150,12 @@ interface CallOptions {
|
||||
json?: unknown
|
||||
/** Send the session token. */
|
||||
authed?: boolean
|
||||
/**
|
||||
* What to say when the worker refuses with a 400 and NO body. Several accounts routes
|
||||
* do exactly that (email, display name, bio), so without this the player reads
|
||||
* "Request failed (400)" — the status, not the reason.
|
||||
*/
|
||||
refusal?: string
|
||||
}
|
||||
|
||||
/** Call a worker. Returns the parsed body, or throws with something worth showing. */
|
||||
@@ -178,6 +184,10 @@ async function call<T = Record<string, unknown>>(url: string, opts: CallOptions
|
||||
setToken(null)
|
||||
throw new Error('Your session has expired. Please sign in again.')
|
||||
}
|
||||
// Only when the body really is empty — a worker that did send a reason keeps it.
|
||||
if (opts.refusal !== undefined && res.status === 400 && Object.keys(data).length === 0) {
|
||||
throw new Error(opts.refusal)
|
||||
}
|
||||
throw new Error(errorMessage(data, res.status))
|
||||
}
|
||||
return data as T
|
||||
@@ -254,10 +264,20 @@ async function changeUsername(username: string): Promise<SelfAccount> {
|
||||
return fetchMe()
|
||||
}
|
||||
|
||||
/** Set the account's email. `accounts` refuses an address with no `@` — with an empty
|
||||
* 400 body, so the check is made here too rather than showing a bare status. */
|
||||
/**
|
||||
* Set the account's email.
|
||||
*
|
||||
* The address is NOT checked here first. `accounts` validates it with `isemail`, which
|
||||
* can't come along into the browser (it reaches for node's `util`, which vite stubs with
|
||||
* a throwing Proxy in dev) — and a second, looser copy of the rule would only disagree
|
||||
* with the real one. The server decides; this just names the refusal it answers with.
|
||||
*/
|
||||
const saveEmail = (email: string): Promise<unknown> =>
|
||||
call(`${where().accounts}/account/me/email`, { form: { email }, authed: true })
|
||||
call(`${where().accounts}/account/me/email`, {
|
||||
form: { email },
|
||||
authed: true,
|
||||
refusal: 'That email address looks wrong.',
|
||||
})
|
||||
|
||||
/** Change the account's password. Lives on `auth`, not `accounts`. */
|
||||
const changePassword = (oldPassword: string, newPassword: string): Promise<unknown> =>
|
||||
@@ -950,15 +970,7 @@ function SignupForm({
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void run(async () => {
|
||||
// Checked before anything is created, because `accounts` rejects an address
|
||||
// with no `@` and by then the account would exist: better to fail the form
|
||||
// than to hand back an account whose email silently didn't save. Same rule
|
||||
// accounts applies, deliberately no stricter — this is a contact address, not
|
||||
// an identity, and nothing is sent to it to prove it.
|
||||
const wanted = email.trim()
|
||||
if (wanted !== '' && !wanted.includes('@')) {
|
||||
throw new Error('That email address looks wrong.')
|
||||
}
|
||||
|
||||
try {
|
||||
await signUp(password, widgetToken)
|
||||
|
||||
@@ -13,5 +13,8 @@
|
||||
"@cloudflare/workers-types": "4.20260630.1",
|
||||
"@repo/tools": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"isemail": "^3.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,3 +9,4 @@ export * from './presence-db'
|
||||
export * from './gifts-db'
|
||||
export * from './inventory-invention-db'
|
||||
export * from './relationships-db'
|
||||
export * from './validation'
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import isEmail from 'isemail'
|
||||
|
||||
/**
|
||||
* Limits on the free text a player can put into their account and their rooms.
|
||||
*
|
||||
* Shared by `accounts` and `rooms` so one rule can't drift from the other — a username
|
||||
* and a room name are held to the same shape, and both are typed into the same client.
|
||||
*
|
||||
* These check only what a player SUPPLIES. Names the server generates go around them:
|
||||
* a dorm is called `@<username>'s Dorm` (see `rooms-db.ts`), which the name rule below
|
||||
* would reject, and auto-assigned usernames (`SwiftFox4821`, `Player42`) happen to
|
||||
* satisfy it. So validate at the request handler, never inside the db helpers.
|
||||
*
|
||||
* Emptiness is deliberately NOT checked here. Every caller already rejects an empty
|
||||
* value in its own words, and those sentences reach players through response envelopes
|
||||
* the client renders verbatim — see the client-contract notes in CLAUDE.md.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Name lengths. All three come from what the CLIENT will accept in the matching input
|
||||
* box, not from a round number: accepting more here would store a name the game can't
|
||||
* re-enter or edit, so the server matches the box rather than being generous.
|
||||
*/
|
||||
export const MAX_USERNAME_LENGTH = 50
|
||||
export const MAX_DISPLAY_NAME_LENGTH = 15
|
||||
export const MAX_ROOM_NAME_LENGTH = 32
|
||||
|
||||
/**
|
||||
* Club and event limits. Longer than the name limits above because these aren't
|
||||
* identifiers — a club name and an event name are titles, and both allow the
|
||||
* punctuation and spaces a title needs (clubs enforce their own charset rule; events
|
||||
* enforce none at all, since an event is called things like "Building a Better Room
|
||||
* Using Trigonometry").
|
||||
*/
|
||||
export const MAX_CLUB_NAME_LENGTH = 40
|
||||
export const MAX_CLUB_DESCRIPTION_LENGTH = 512
|
||||
export const MAX_EVENT_NAME_LENGTH = 64
|
||||
export const MAX_EVENT_DESCRIPTION_LENGTH = 512
|
||||
|
||||
/**
|
||||
* Length in code points rather than UTF-16 units, so an emoji or other astral character
|
||||
* counts once instead of twice — the way a player counts what they typed.
|
||||
*/
|
||||
export const glyphLength = (value: string): number => Array.from(value).length
|
||||
|
||||
/** Max length of a profile bio. */
|
||||
export const MAX_BIO_LENGTH = 255
|
||||
|
||||
/**
|
||||
* Letters and digits only — no spaces, punctuation, or accents.
|
||||
*
|
||||
* Deliberately narrow: these names are shown to other players, used to search, and (for
|
||||
* usernames) typed into a sign-in box, so anything that can be confused for another name
|
||||
* is worth refusing. It also rules out the homoglyph and right-to-left tricks that come
|
||||
* with allowing arbitrary Unicode.
|
||||
*/
|
||||
const NAME_PATTERN = /^[A-Za-z0-9]+$/
|
||||
|
||||
/**
|
||||
* Why a player-supplied name is unacceptable, or `null` when it's fine.
|
||||
*
|
||||
* `label` names the thing in the returned sentence ('username', 'room name'), so the
|
||||
* message reads correctly wherever it's surfaced. `max` is required rather than
|
||||
* defaulted: the three limits differ, and a caller that forgets which one it wants
|
||||
* should have to say so instead of silently taking someone else's.
|
||||
*/
|
||||
export function nameRejection(value: string, label: string, max: number): string | null {
|
||||
if (value.length > max) {
|
||||
return `Your ${label} can be at most ${max} characters.`
|
||||
}
|
||||
if (!NAME_PATTERN.test(value)) {
|
||||
return `Your ${label} can only contain letters and numbers.`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a supplied email is one worth storing — RFC 5321/5322 syntax, via `isemail`.
|
||||
*
|
||||
* A hand-rolled pattern is the wrong shape of work here: this is a contact address
|
||||
* nothing is ever sent to in order to prove it, so the only thing a stricter regex buys
|
||||
* is more edge cases to get wrong. Note it also enforces the RFC's 254-character maximum
|
||||
* itself, which is why there's no separate length cap.
|
||||
*
|
||||
* It accepts a dotless domain (`someone@localhost`), which a dotted-domain rule would
|
||||
* refuse. That's the RFC being right and the shortcut being wrong, and an undeliverable
|
||||
* address costs nothing here.
|
||||
*/
|
||||
export function isValidEmail(value: string): boolean {
|
||||
return isEmail.validate(value)
|
||||
}
|
||||
|
||||
/** Whether a supplied bio is within the stored length. */
|
||||
export function isValidBio(value: string): boolean {
|
||||
return value.length <= MAX_BIO_LENGTH
|
||||
}
|
||||
Generated
+18
@@ -864,6 +864,10 @@ importers:
|
||||
version: 4.105.0(@cloudflare/workers-types@4.20260630.1)
|
||||
|
||||
packages/domain:
|
||||
dependencies:
|
||||
isemail:
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.0
|
||||
devDependencies:
|
||||
'@cloudflare/workers-types':
|
||||
specifier: 4.20260630.1
|
||||
@@ -3174,6 +3178,10 @@ packages:
|
||||
resolution: {integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
isemail@3.2.0:
|
||||
resolution: {integrity: sha512-zKqkK+O+dGqevc93KNsbZ/TqTUFd46MwWjYOoMrjIMZ51eU7DtQG3Wmd9SQQT7i7RVnuTPEiYEWHU3MSbxC1Tg==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
|
||||
jiti@2.6.1:
|
||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
@@ -3542,6 +3550,10 @@ packages:
|
||||
property-information@7.2.0:
|
||||
resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
|
||||
|
||||
punycode@2.3.1:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
quansync@0.2.11:
|
||||
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
||||
|
||||
@@ -6154,6 +6166,10 @@ snapshots:
|
||||
|
||||
is-regexp@3.1.0: {}
|
||||
|
||||
isemail@3.2.0:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
jiti@2.6.1:
|
||||
optional: true
|
||||
|
||||
@@ -6677,6 +6693,8 @@ snapshots:
|
||||
|
||||
property-information@7.2.0: {}
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
quansync@0.2.11: {}
|
||||
|
||||
radix-vue@1.9.17(vue@3.5.40(typescript@6.0.3)):
|
||||
|
||||
Reference in New Issue
Block a user