chore: cleanup images (#14380)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added canary build version support with automatic validation and
age-based restrictions for testing pre-release versions.

* **Chores**
* Enhanced Docker build process with multi-stage builds, image
optimization, and memory allocation improvements.
  * Reorganized dependencies to distinguish development-only packages.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-02-06 19:49:02 +08:00
committed by GitHub
parent 8c15df489b
commit a0cf5681c4
12 changed files with 793 additions and 8 deletions

View File

@@ -4,7 +4,7 @@ import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import request from 'supertest';
import { ConfigFactory } from '../../base';
import { CANARY_CLIENT_VERSION_MAX_AGE_DAYS, ConfigFactory } from '../../base';
import { AuthModule, CurrentUser, Public, Session } from '../../core/auth';
import { AuthService } from '../../core/auth/service';
import { Models } from '../../models';
@@ -29,6 +29,10 @@ class TestController {
}
}
function makeCanaryDateVersion(date: Date, build = '015') {
return `${date.getUTCFullYear()}.${date.getUTCMonth() + 1}.${date.getUTCDate()}-canary.${build}`;
}
const test = ava as TestFn<{
app: TestingApp;
server: any;
@@ -272,3 +276,68 @@ test('should not block public handler when client version is unsupported', async
t.true(setCookies.some(c => c.startsWith(`${AuthService.userCookieName}=`)));
t.true(setCookies.some(c => c.startsWith(`${AuthService.csrfCookieName}=`)));
});
test('should allow recent canary date version in canary namespace', async t => {
t.context.config.override({
client: {
versionControl: {
enabled: true,
requiredVersion: '>=0.25.0',
},
},
});
const prevNamespace = env.NAMESPACE;
// @ts-expect-error test
env.NAMESPACE = 'dev';
try {
const res = await request(t.context.server)
.get('/private')
.set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
.set('x-affine-version', makeCanaryDateVersion(new Date(), '015'))
.expect(200);
t.is(res.body.user.id, t.context.u1.id);
} finally {
// @ts-expect-error test
env.NAMESPACE = prevNamespace;
}
});
test('should kick out old canary date version in canary namespace', async t => {
t.context.config.override({
client: {
versionControl: {
enabled: true,
requiredVersion: '>=0.25.0',
},
},
});
const prevNamespace = env.NAMESPACE;
// @ts-expect-error test
env.NAMESPACE = 'dev';
try {
const old = new Date(
Date.now() -
(CANARY_CLIENT_VERSION_MAX_AGE_DAYS + 1) * 24 * 60 * 60 * 1000
);
const oldVersion = makeCanaryDateVersion(old, '015');
const res = await request(t.context.server)
.get('/private')
.set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
.set('x-affine-version', oldVersion)
.expect(403);
t.is(
res.body.message,
`Unsupported client with version [${oldVersion}], required version is [canary (within 2 months)].`
);
} finally {
// @ts-expect-error test
env.NAMESPACE = prevNamespace;
}
});

View File

@@ -2,6 +2,7 @@ import test, { type ExecutionContext } from 'ava';
import { io, type Socket as SocketIOClient } from 'socket.io-client';
import { Doc, encodeStateAsUpdate } from 'yjs';
import { CANARY_CLIENT_VERSION_MAX_AGE_DAYS } from '../../base';
import { createTestingApp, TestingApp } from '../utils';
type WebsocketResponse<T> =
@@ -10,6 +11,10 @@ type WebsocketResponse<T> =
const WS_TIMEOUT_MS = 5_000;
function makeCanaryDateVersion(date: Date, build = '015') {
return `${date.getUTCFullYear()}.${date.getUTCMonth() + 1}.${date.getUTCDate()}-canary.${build}`;
}
function unwrapResponse<T>(t: ExecutionContext, res: WebsocketResponse<T>): T {
if ('data' in res) {
return res.data;
@@ -285,6 +290,83 @@ test('clientVersion>=0.26.0 should only receive space:broadcast-doc-updates', as
}
});
test('canary date clientVersion should use sync-026 in canary namespace', async t => {
const prevNamespace = env.NAMESPACE;
// @ts-expect-error test
env.NAMESPACE = 'dev';
try {
const { user, cookieHeader } = await login(app);
const spaceId = user.id;
const update = createYjsUpdateBase64();
const sender = createClient(url, cookieHeader);
const receiver = createClient(url, cookieHeader);
try {
await Promise.all([waitForConnect(sender), waitForConnect(receiver)]);
const receiverJoin = unwrapResponse(
t,
await emitWithAck<{ clientId: string; success: boolean }>(
receiver,
'space:join',
{
spaceType: 'userspace',
spaceId,
clientVersion: makeCanaryDateVersion(new Date(), '015'),
}
)
);
t.true(receiverJoin.success);
const senderJoin = unwrapResponse(
t,
await emitWithAck<{ clientId: string; success: boolean }>(
sender,
'space:join',
{ spaceType: 'userspace', spaceId, clientVersion: '0.25.0' }
)
);
t.true(senderJoin.success);
const onUpdates = waitForEvent<{
spaceType: string;
spaceId: string;
docId: string;
updates: string[];
}>(receiver, 'space:broadcast-doc-updates');
const noUpdate = expectNoEvent(receiver, 'space:broadcast-doc-update');
const pushRes = await emitWithAck<{ accepted: true; timestamp?: number }>(
sender,
'space:push-doc-update',
{
spaceType: 'userspace',
spaceId,
docId: 'doc-canary',
update,
}
);
unwrapResponse(t, pushRes);
const message = await onUpdates;
t.is(message.spaceType, 'userspace');
t.is(message.spaceId, spaceId);
t.is(message.docId, 'doc-canary');
t.deepEqual(message.updates, [update]);
await noUpdate;
} finally {
sender.disconnect();
receiver.disconnect();
}
} finally {
// @ts-expect-error test
env.NAMESPACE = prevNamespace;
}
});
test('clientVersion<0.25.0 should be rejected and disconnected', async t => {
const { user, cookieHeader } = await login(app);
const spaceId = user.id;
@@ -309,6 +391,48 @@ test('clientVersion<0.25.0 should be rejected and disconnected', async t => {
}
});
test('old canary date clientVersion should be rejected and disconnected in canary namespace', async t => {
const prevNamespace = env.NAMESPACE;
// @ts-expect-error test
env.NAMESPACE = 'dev';
try {
const { user, cookieHeader } = await login(app);
const spaceId = user.id;
const socket = createClient(url, cookieHeader);
try {
await waitForConnect(socket);
const old = new Date(
Date.now() -
(CANARY_CLIENT_VERSION_MAX_AGE_DAYS + 1) * 24 * 60 * 60 * 1000
);
const res = unwrapResponse(
t,
await emitWithAck<{ clientId: string; success: boolean }>(
socket,
'space:join',
{
spaceType: 'userspace',
spaceId,
clientVersion: makeCanaryDateVersion(old, '015'),
}
)
);
t.false(res.success);
await waitForDisconnect(socket);
} finally {
socket.disconnect();
}
} finally {
// @ts-expect-error test
env.NAMESPACE = prevNamespace;
}
});
test('space:join-awareness should reject clientVersion<0.25.0', async t => {
const { user, cookieHeader } = await login(app);
const spaceId = user.id;

View File

@@ -3,7 +3,11 @@ import test from 'ava';
import Sinon from 'sinon';
import { AppModule } from '../app.module';
import { ConfigFactory, UseNamedGuard } from '../base';
import {
CANARY_CLIENT_VERSION_MAX_AGE_DAYS,
ConfigFactory,
UseNamedGuard,
} from '../base';
import { Public } from '../core/auth/guard';
import { VersionService } from '../core/version/service';
import { createTestingApp, TestingApp } from './utils';
@@ -33,6 +37,10 @@ function checkVersion(enabled = true) {
});
}
function makeCanaryDateVersion(date: Date, build = '015') {
return `${date.getUTCFullYear()}.${date.getUTCMonth() + 1}.${date.getUTCDate()}-canary.${build}`;
}
test.before(async () => {
app = await createTestingApp({
imports: [AppModule],
@@ -197,3 +205,47 @@ test('should test prerelease version', async t => {
t.is(res.status, 200);
});
test('should allow recent canary date version in canary namespace', async t => {
const prevNamespace = env.NAMESPACE;
// @ts-expect-error test
env.NAMESPACE = 'dev';
try {
const res = await app
.GET('/guarded/test')
.set('x-affine-version', makeCanaryDateVersion(new Date(), '015'));
t.is(res.status, 200);
} finally {
// @ts-expect-error test
env.NAMESPACE = prevNamespace;
}
});
test('should reject old canary date version in canary namespace', async t => {
const prevNamespace = env.NAMESPACE;
// @ts-expect-error test
env.NAMESPACE = 'dev';
try {
const old = new Date(
Date.now() -
(CANARY_CLIENT_VERSION_MAX_AGE_DAYS + 1) * 24 * 60 * 60 * 1000
);
const oldVersion = makeCanaryDateVersion(old, '015');
const res = await app
.GET('/guarded/test')
.set('x-affine-version', oldVersion);
t.is(res.status, 403);
t.is(
res.body.message,
`Unsupported client with version [${oldVersion}], required version is [canary (within 2 months)].`
);
} finally {
// @ts-expect-error test
env.NAMESPACE = prevNamespace;
}
});

View File

@@ -0,0 +1,91 @@
const DAY_MS = 24 * 60 * 60 * 1000;
// Example: 2026.2.6-canary.015
const CANARY_DATE_VERSION_RE =
/^v?(\d{4})\.(\d{1,2})\.(\d{1,2})-canary\.(\d+)(?:\+.*)?$/i;
export const CANARY_CLIENT_VERSION_MAX_AGE_DAYS = 62; // ~2 months
export const CANARY_CLIENT_VERSION_MAX_FUTURE_SKEW_DAYS = 2;
export type CanaryDateClientVersion = {
raw: string;
normalized: string;
dateMs: number;
};
export function parseCanaryDateClientVersion(
version: string
): CanaryDateClientVersion | null {
const raw = version.trim();
const match = CANARY_DATE_VERSION_RE.exec(raw);
if (!match) {
return null;
}
const year = Number.parseInt(match[1], 10);
const month = Number.parseInt(match[2], 10);
const day = Number.parseInt(match[3], 10);
const build = match[4].replace(/^0+(?=\d)/, '');
if (
!Number.isInteger(year) ||
!Number.isInteger(month) ||
!Number.isInteger(day) ||
year < 0 ||
month < 1 ||
month > 12 ||
day < 1 ||
day > 31
) {
return null;
}
const dateMs = Date.UTC(year, month - 1, day);
const date = new Date(dateMs);
if (
date.getUTCFullYear() !== year ||
date.getUTCMonth() !== month - 1 ||
date.getUTCDate() !== day
) {
return null;
}
return {
raw,
normalized: `${year}.${month}.${day}-canary.${build}`,
dateMs,
};
}
export type CanaryClientVersionCheckResult =
| { matched: false }
| { matched: true; allowed: boolean; normalized: string };
export function checkCanaryDateClientVersion(
version: string,
options?: {
nowMs?: number;
maxAgeDays?: number;
maxFutureSkewDays?: number;
}
): CanaryClientVersionCheckResult {
const parsed = parseCanaryDateClientVersion(version);
if (!parsed) {
return { matched: false };
}
const nowMs = options?.nowMs ?? Date.now();
const maxAgeDays = options?.maxAgeDays ?? CANARY_CLIENT_VERSION_MAX_AGE_DAYS;
const maxFutureSkewDays =
options?.maxFutureSkewDays ?? CANARY_CLIENT_VERSION_MAX_FUTURE_SKEW_DAYS;
const ageMs = nowMs - parsed.dateMs;
const maxAgeMs = maxAgeDays * DAY_MS;
const maxFutureSkewMs = maxFutureSkewDays * DAY_MS;
return {
matched: true,
allowed: ageMs <= maxAgeMs && ageMs >= -maxFutureSkewMs,
normalized: parsed.normalized,
};
}

View File

@@ -1,3 +1,4 @@
export * from './client-version';
export * from './duration';
export * from './promise';
export * from './request';

View File

@@ -14,6 +14,7 @@ import {
AccessDenied,
AuthenticationRequired,
Cache,
checkCanaryDateClientVersion,
Config,
CryptoHelper,
getClientVersionFromRequest,
@@ -35,6 +36,7 @@ export class AuthGuard implements CanActivate, OnModuleInit {
private auth!: AuthService;
private readonly cachedVersionRange = new Map<string, semver.Range | null>();
private static readonly HARD_REQUIRED_VERSION = '>=0.25.0';
private static readonly CANARY_REQUIRED_VERSION = 'canary (within 2 months)';
constructor(
private readonly crypto: CryptoHelper,
@@ -218,6 +220,15 @@ export class AuthGuard implements CanActivate, OnModuleInit {
): { ok: true } | { ok: false; requiredVersion: string } {
const requiredVersion = this.config.client.versionControl.requiredVersion;
if (clientVersion && env.namespaces.canary) {
const canaryCheck = checkCanaryDateClientVersion(clientVersion);
if (canaryCheck.matched) {
return canaryCheck.allowed
? { ok: true }
: { ok: false, requiredVersion: AuthGuard.CANARY_REQUIRED_VERSION };
}
}
const configRange = this.getVersionRange(requiredVersion);
if (
configRange &&

View File

@@ -14,6 +14,7 @@ import { type Server, Socket } from 'socket.io';
import {
CallMetric,
checkCanaryDateClientVersion,
DocNotFound,
DocUpdateBlocked,
EventBus,
@@ -71,14 +72,33 @@ const DOC_UPDATES_PROTOCOL_026 = new semver.Range('>=0.26.0-0', {
type SyncProtocolRoomType = Extract<RoomType, 'sync-025' | 'sync-026'>;
function normalizeWsClientVersion(clientVersion: string): string | null {
if (env.namespaces.canary) {
const canaryCheck = checkCanaryDateClientVersion(clientVersion);
if (canaryCheck.matched) {
return canaryCheck.allowed ? canaryCheck.normalized : null;
}
}
return clientVersion;
}
function isSupportedWsClientVersion(clientVersion: string): boolean {
const normalized = normalizeWsClientVersion(clientVersion);
if (!normalized) {
return false;
}
return Boolean(
semver.valid(clientVersion) && MIN_WS_CLIENT_VERSION.test(clientVersion)
semver.valid(normalized) && MIN_WS_CLIENT_VERSION.test(normalized)
);
}
function getSyncProtocolRoomType(clientVersion: string): SyncProtocolRoomType {
return DOC_UPDATES_PROTOCOL_026.test(clientVersion) ? 'sync-026' : 'sync-025';
const normalized = normalizeWsClientVersion(clientVersion);
return DOC_UPDATES_PROTOCOL_026.test(normalized ?? clientVersion)
? 'sync-026'
: 'sync-025';
}
enum SpaceType {

View File

@@ -1,18 +1,37 @@
import { Injectable, Logger } from '@nestjs/common';
import semver from 'semver';
import { Config, UnsupportedClientVersion } from '../../base';
import {
checkCanaryDateClientVersion,
Config,
UnsupportedClientVersion,
} from '../../base';
@Injectable()
export class VersionService {
private readonly logger = new Logger(VersionService.name);
private static readonly HARD_REQUIRED_VERSION = '>=0.25.0';
private static readonly CANARY_REQUIRED_VERSION = 'canary (within 2 months)';
constructor(private readonly config: Config) {}
async checkVersion(clientVersion?: string) {
const requiredVersion = this.config.client.versionControl.requiredVersion;
if (clientVersion && env.namespaces.canary) {
const canaryCheck = checkCanaryDateClientVersion(clientVersion);
if (canaryCheck.matched) {
if (canaryCheck.allowed) {
return true;
}
throw new UnsupportedClientVersion({
clientVersion,
requiredVersion: VersionService.CANARY_REQUIRED_VERSION,
});
}
}
const hardRange = await this.getVersionRange(
VersionService.HARD_REQUIRED_VERSION
);