From 2673f013be2284746d2639eb1283d91009b85058 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 4 Apr 2023 11:19:45 -0500 Subject: [PATCH 01/15] getUserInviteCodes lex --- .../atproto/server/getUserInviteCodes.json | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 lexicons/com/atproto/server/getUserInviteCodes.json diff --git a/lexicons/com/atproto/server/getUserInviteCodes.json b/lexicons/com/atproto/server/getUserInviteCodes.json new file mode 100644 index 00000000000..8bc2c9ce8ad --- /dev/null +++ b/lexicons/com/atproto/server/getUserInviteCodes.json @@ -0,0 +1,42 @@ +{ + "lexicon": 1, + "id": "com.atproto.server.getUserInviteCodes", + "defs": { + "main": { + "type": "query", + "description": "Get all invite codes for a given user", + "parameters": { + "type": "params", + "properties": { + "includeUsed": { "type": "boolean", "default": true } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["codes"], + "properties": { + "codes": { + "type": "array", + "items": { + "type": "ref", + "ref": "#invite" + } + } + } + } + } + }, + "invite": { + "type": "object", + "requred": ["code", "available", "uses", "canCreate"], + "properties": { + "code": { "type": "string" }, + "available": { "type": "integer" }, + "users": { "type": "integer" }, + "canCreate": { "type": "integer" } + } + } + } +} From 8dcf2dd7e8ae3a547565ed46853053d434f202c9 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 4 Apr 2023 11:21:11 -0500 Subject: [PATCH 02/15] small change --- lexicons/com/atproto/server/getUserInviteCodes.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lexicons/com/atproto/server/getUserInviteCodes.json b/lexicons/com/atproto/server/getUserInviteCodes.json index 8bc2c9ce8ad..cb0fa219e9d 100644 --- a/lexicons/com/atproto/server/getUserInviteCodes.json +++ b/lexicons/com/atproto/server/getUserInviteCodes.json @@ -30,12 +30,11 @@ }, "invite": { "type": "object", - "requred": ["code", "available", "uses", "canCreate"], + "requred": ["code", "available", "uses"], "properties": { "code": { "type": "string" }, "available": { "type": "integer" }, - "users": { "type": "integer" }, - "canCreate": { "type": "integer" } + "users": { "type": "integer" } } } } From c2f10e8d8dd2697467788ba5240a21bc57a5b947 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 4 Apr 2023 12:12:14 -0500 Subject: [PATCH 03/15] implement user invite code creation/getting --- .../atproto/server/getUserInviteCodes.json | 7 +- packages/api/src/client/index.ts | 13 ++++ packages/api/src/client/lexicons.ts | 55 ++++++++++++++ .../com/atproto/server/getUserInviteCodes.ts | 55 ++++++++++++++ .../com/atproto/server/createInviteCode.ts | 12 +-- .../com/atproto/server/getUserInviteCodes.ts | 76 +++++++++++++++++++ .../pds/src/api/com/atproto/server/index.ts | 2 + .../pds/src/api/com/atproto/server/util.ts | 19 +++++ packages/pds/src/config.ts | 33 ++++++-- packages/pds/src/lexicon/index.ts | 11 +++ packages/pds/src/lexicon/lexicons.ts | 55 ++++++++++++++ .../com/atproto/server/getUserInviteCodes.ts | 61 +++++++++++++++ 12 files changed, 380 insertions(+), 19 deletions(-) create mode 100644 packages/api/src/client/types/com/atproto/server/getUserInviteCodes.ts create mode 100644 packages/pds/src/api/com/atproto/server/getUserInviteCodes.ts create mode 100644 packages/pds/src/api/com/atproto/server/util.ts create mode 100644 packages/pds/src/lexicon/types/com/atproto/server/getUserInviteCodes.ts diff --git a/lexicons/com/atproto/server/getUserInviteCodes.json b/lexicons/com/atproto/server/getUserInviteCodes.json index cb0fa219e9d..58fe25f5061 100644 --- a/lexicons/com/atproto/server/getUserInviteCodes.json +++ b/lexicons/com/atproto/server/getUserInviteCodes.json @@ -8,7 +8,8 @@ "parameters": { "type": "params", "properties": { - "includeUsed": { "type": "boolean", "default": true } + "includeUsed": { "type": "boolean", "default": true }, + "createAvailable": { "type": "boolean", "default": true } } }, "output": { @@ -30,11 +31,11 @@ }, "invite": { "type": "object", - "requred": ["code", "available", "uses"], + "required": ["code", "available", "uses"], "properties": { "code": { "type": "string" }, "available": { "type": "integer" }, - "users": { "type": "integer" } + "uses": { "type": "integer" } } } } diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index ece40548033..45344514ff2 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -38,6 +38,7 @@ import * as ComAtprotoServerDeleteAccount from './types/com/atproto/server/delet import * as ComAtprotoServerDeleteSession from './types/com/atproto/server/deleteSession' import * as ComAtprotoServerDescribeServer from './types/com/atproto/server/describeServer' import * as ComAtprotoServerGetSession from './types/com/atproto/server/getSession' +import * as ComAtprotoServerGetUserInviteCodes from './types/com/atproto/server/getUserInviteCodes' import * as ComAtprotoServerRefreshSession from './types/com/atproto/server/refreshSession' import * as ComAtprotoServerRequestAccountDelete from './types/com/atproto/server/requestAccountDelete' import * as ComAtprotoServerRequestPasswordReset from './types/com/atproto/server/requestPasswordReset' @@ -116,6 +117,7 @@ export * as ComAtprotoServerDeleteAccount from './types/com/atproto/server/delet export * as ComAtprotoServerDeleteSession from './types/com/atproto/server/deleteSession' export * as ComAtprotoServerDescribeServer from './types/com/atproto/server/describeServer' export * as ComAtprotoServerGetSession from './types/com/atproto/server/getSession' +export * as ComAtprotoServerGetUserInviteCodes from './types/com/atproto/server/getUserInviteCodes' export * as ComAtprotoServerRefreshSession from './types/com/atproto/server/refreshSession' export * as ComAtprotoServerRequestAccountDelete from './types/com/atproto/server/requestAccountDelete' export * as ComAtprotoServerRequestPasswordReset from './types/com/atproto/server/requestPasswordReset' @@ -580,6 +582,17 @@ export class ServerNS { }) } + getUserInviteCodes( + params?: ComAtprotoServerGetUserInviteCodes.QueryParams, + opts?: ComAtprotoServerGetUserInviteCodes.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.server.getUserInviteCodes', params, undefined, opts) + .catch((e) => { + throw ComAtprotoServerGetUserInviteCodes.toKnownErr(e) + }) + } + refreshSession( data?: ComAtprotoServerRefreshSession.InputSchema, opts?: ComAtprotoServerRefreshSession.CallOptions, diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 9a277fb1dcf..777794edd25 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -1889,6 +1889,60 @@ export const schemaDict = { }, }, }, + ComAtprotoServerGetUserInviteCodes: { + lexicon: 1, + id: 'com.atproto.server.getUserInviteCodes', + defs: { + main: { + type: 'query', + description: 'Get all invite codes for a given user', + parameters: { + type: 'params', + properties: { + includeUsed: { + type: 'boolean', + default: true, + }, + createAvailable: { + type: 'boolean', + default: true, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['codes'], + properties: { + codes: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.server.getUserInviteCodes#invite', + }, + }, + }, + }, + }, + }, + invite: { + type: 'object', + required: ['code', 'available', 'uses'], + properties: { + code: { + type: 'string', + }, + available: { + type: 'integer', + }, + uses: { + type: 'integer', + }, + }, + }, + }, + }, ComAtprotoServerRefreshSession: { lexicon: 1, id: 'com.atproto.server.refreshSession', @@ -4190,6 +4244,7 @@ export const ids = { ComAtprotoServerDeleteSession: 'com.atproto.server.deleteSession', ComAtprotoServerDescribeServer: 'com.atproto.server.describeServer', ComAtprotoServerGetSession: 'com.atproto.server.getSession', + ComAtprotoServerGetUserInviteCodes: 'com.atproto.server.getUserInviteCodes', ComAtprotoServerRefreshSession: 'com.atproto.server.refreshSession', ComAtprotoServerRequestAccountDelete: 'com.atproto.server.requestAccountDelete', diff --git a/packages/api/src/client/types/com/atproto/server/getUserInviteCodes.ts b/packages/api/src/client/types/com/atproto/server/getUserInviteCodes.ts new file mode 100644 index 00000000000..f1bfaddfd6e --- /dev/null +++ b/packages/api/src/client/types/com/atproto/server/getUserInviteCodes.ts @@ -0,0 +1,55 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { Headers, XRPCError } from '@atproto/xrpc' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { isObj, hasProp } from '../../../../util' +import { lexicons } from '../../../../lexicons' +import { CID } from 'multiformats/cid' + +export interface QueryParams { + includeUsed?: boolean + createAvailable?: boolean +} + +export type InputSchema = undefined + +export interface OutputSchema { + codes: Invite[] + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} + +export interface Invite { + code: string + available: number + uses: number + [k: string]: unknown +} + +export function isInvite(v: unknown): v is Invite { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.server.getUserInviteCodes#invite' + ) +} + +export function validateInvite(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.server.getUserInviteCodes#invite', v) +} diff --git a/packages/pds/src/api/com/atproto/server/createInviteCode.ts b/packages/pds/src/api/com/atproto/server/createInviteCode.ts index 6c4445c9b3c..f54a823f257 100644 --- a/packages/pds/src/api/com/atproto/server/createInviteCode.ts +++ b/packages/pds/src/api/com/atproto/server/createInviteCode.ts @@ -1,7 +1,6 @@ -import * as crypto from '@atproto/crypto' -import * as uint8arrays from 'uint8arrays' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { genInvCode } from './util' export default function (server: Server, ctx: AppContext) { server.com.atproto.server.createInviteCode({ @@ -9,14 +8,7 @@ export default function (server: Server, ctx: AppContext) { handler: async ({ input, req }) => { const { useCount } = input.body - // generate a 7 char b32 invite code - preceded by the hostname - // with '.'s replaced by '-'s so it is not mistakable for a link - // ex: bsky-app-abc2345 - // regex: bsky-app-[a-z2-7]{7} - const code = - ctx.cfg.publicHostname.replaceAll('.', '-') + - '-' + - uint8arrays.toString(await crypto.randomBytes(7), 'base32').slice(0, 7) + const code = genInvCode(ctx.cfg) await ctx.db.db .insertInto('invite_code') diff --git a/packages/pds/src/api/com/atproto/server/getUserInviteCodes.ts b/packages/pds/src/api/com/atproto/server/getUserInviteCodes.ts new file mode 100644 index 00000000000..0e6ef858381 --- /dev/null +++ b/packages/pds/src/api/com/atproto/server/getUserInviteCodes.ts @@ -0,0 +1,76 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { genInvCodes } from './util' +import { sql } from 'kysely' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.server.getUserInviteCodes({ + auth: ctx.accessVerifierCheckTakedown, + handler: async ({ params, auth }) => { + const requester = auth.credentials.did + const { includeUsed, createAvailable } = params + + const [user, userCodes] = await Promise.all([ + ctx.db.db + .selectFrom('user_account') + .where('did', '=', requester) + .select('createdAt') + .executeTakeFirstOrThrow(), + ctx.db.db + .selectFrom('invite_code') + .innerJoin( + 'invite_code_use', + 'invite_code_use.code', + 'invite_code.code', + ) + .where('forUser', '=', requester) + .groupBy('invite_code.code') + .select([ + 'invite_code.code as code', + 'invite_code.availableUses as available', + sql`count(invite_code_use.usedBy)`.as('uses'), + ]) + .execute(), + ]) + + const unusedCodes = userCodes.filter((row) => row.available > row.uses) + + let created: string[] = [] + + // if the user wishes to create available codes & the server allows that, + // we determine the number to create by dividing their account lifetime by the interval at which they can create codes + // we allow a max of 5 open codes at a given time + if (createAvailable && ctx.cfg.userInviteInterval !== null) { + const accountLifespan = Date.now() - new Date(user.createdAt).getTime() + const couldCreate = Math.floor( + accountLifespan / ctx.cfg.userInviteInterval, + ) + const toCreate = Math.min(5 - unusedCodes.length, couldCreate) + if (toCreate > 0) { + created = genInvCodes(ctx.cfg, toCreate) + const rows = created.map((code) => ({ + code: code, + availableUses: 1, + disabled: 0 as const, + forUser: requester, + createdBy: requester, + createdAt: new Date().toISOString(), + })) + await ctx.db.db.insertInto('invite_code').values(rows).execute() + } + } + + const toReturn = [ + ...(includeUsed ? userCodes : unusedCodes), + ...created.map((code) => ({ code: code, available: 1, uses: 0 })), + ] + + return { + encoding: 'application/json', + body: { + codes: toReturn, + }, + } + }, + }) +} diff --git a/packages/pds/src/api/com/atproto/server/index.ts b/packages/pds/src/api/com/atproto/server/index.ts index ed084ebb15a..e8227666318 100644 --- a/packages/pds/src/api/com/atproto/server/index.ts +++ b/packages/pds/src/api/com/atproto/server/index.ts @@ -5,6 +5,7 @@ import describeServer from './describeServer' import createAccount from './createAccount' import createInviteCode from './createInviteCode' +import getUserInviteCodes from './getUserInviteCodes' import requestDelete from './requestAccountDelete' import deleteAccount from './deleteAccount' @@ -21,6 +22,7 @@ export default function (server: Server, ctx: AppContext) { describeServer(server, ctx) createAccount(server, ctx) createInviteCode(server, ctx) + getUserInviteCodes(server, ctx) requestDelete(server, ctx) deleteAccount(server, ctx) requestPasswordReset(server, ctx) diff --git a/packages/pds/src/api/com/atproto/server/util.ts b/packages/pds/src/api/com/atproto/server/util.ts new file mode 100644 index 00000000000..30087b5bfcd --- /dev/null +++ b/packages/pds/src/api/com/atproto/server/util.ts @@ -0,0 +1,19 @@ +import * as crypto from '@atproto/crypto' +import { ServerConfig } from '../../../../config' + +// generate a 7 char b32 invite code - preceded by the hostname +// with '.'s replaced by '-'s so it is not mistakable for a link +// ex: bsky-app-abc2345 +// regex: bsky-app-[a-z2-7]{7} +export const genInvCode = (cfg: ServerConfig): string => { + const code = crypto.randomStr(7, 'base32').slice(0, 7) + return cfg.publicHostname.replaceAll('.', '-') + '-' + code +} + +export const genInvCodes = (cfg: ServerConfig, count: number): string[] => { + const codes: string[] = [] + for (let i = 0; i < count; i++) { + codes.push(genInvCode(cfg)) + } + return codes +} diff --git a/packages/pds/src/config.ts b/packages/pds/src/config.ts index 2ac0a3b0f8c..b72564bc988 100644 --- a/packages/pds/src/config.ts +++ b/packages/pds/src/config.ts @@ -24,6 +24,7 @@ export interface ServerConfigValues { adminPassword: string inviteRequired: boolean + userInviteInterval: number | null privacyPolicyUrl?: string termsOfServiceUrl?: string @@ -68,8 +69,7 @@ export class ServerConfig { } else { scheme = hostname === 'localhost' ? 'http' : 'https' } - const envPort = parseInt(process.env.PORT || '', 10) - const port = isNaN(envPort) ? 2583 : envPort + const port = parseIntWithFallback(process.env.PORT, 2583) const jwtSecret = process.env.JWT_SECRET || 'jwt_secret' @@ -88,6 +88,10 @@ export class ServerConfig { const adminPassword = process.env.ADMIN_PASSWORD || 'admin' const inviteRequired = process.env.INVITE_REQUIRED === 'true' ? true : false + const userInviteInterval = parseIntWithFallback( + process.env.USER_INVITE_INTERVAL, + null, + ) const privacyPolicyUrl = process.env.PRIVACY_POLICY_URL const termsOfServiceUrl = process.env.TERMS_OF_SERVICE_URL @@ -119,11 +123,15 @@ export class ServerConfig { const dbPostgresUrl = process.env.DB_POSTGRES_URL const dbPostgresSchema = process.env.DB_POSTGRES_SCHEMA - const maxBuffer = parseInt(process.env.MAX_SUBSCRIPTION_BUFFER || '', 10) - const maxSubscriptionBuffer = isNaN(maxBuffer) ? 500 : maxBuffer + const maxSubscriptionBuffer = parseIntWithFallback( + process.env.MAX_SUBSCRIPTION_BUFFER, + 500, + ) - const backfillLimit = parseInt(process.env.REPO_BACKFILL_LIMIT_MS || '', 10) - const repoBackfillLimitMs = isNaN(backfillLimit) ? DAY : backfillLimit + const repoBackfillLimitMs = parseIntWithFallback( + process.env.REPO_BACKFILL_LIMIT_MS, + DAY, + ) // E.g. ws://abc.com:4000 const appViewRepoProvider = process.env.APP_VIEW_REPO_PROVIDER || undefined @@ -145,6 +153,7 @@ export class ServerConfig { serverDid, adminPassword, inviteRequired, + userInviteInterval, privacyPolicyUrl, termsOfServiceUrl, databaseLocation, @@ -241,6 +250,10 @@ export class ServerConfig { return this.cfg.inviteRequired } + get userInviteInterval() { + return this.cfg.userInviteInterval + } + get privacyPolicyUrl() { if ( this.cfg.privacyPolicyUrl && @@ -313,3 +326,11 @@ export class ServerConfig { return this.cfg.appViewRepoProvider } } + +const parseIntWithFallback = ( + value: string | undefined, + fallback: T, +): number | T => { + const parsed = parseInt(value || '', 10) + return isNaN(parsed) ? fallback : parsed +} diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index 589086ef58c..3a64fdaeb63 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -37,6 +37,7 @@ import * as ComAtprotoServerDeleteAccount from './types/com/atproto/server/delet import * as ComAtprotoServerDeleteSession from './types/com/atproto/server/deleteSession' import * as ComAtprotoServerDescribeServer from './types/com/atproto/server/describeServer' import * as ComAtprotoServerGetSession from './types/com/atproto/server/getSession' +import * as ComAtprotoServerGetUserInviteCodes from './types/com/atproto/server/getUserInviteCodes' import * as ComAtprotoServerRefreshSession from './types/com/atproto/server/refreshSession' import * as ComAtprotoServerRequestAccountDelete from './types/com/atproto/server/requestAccountDelete' import * as ComAtprotoServerRequestPasswordReset from './types/com/atproto/server/requestPasswordReset' @@ -390,6 +391,16 @@ export class ServerNS { return this._server.xrpc.method(nsid, cfg) } + getUserInviteCodes( + cfg: ConfigOf< + AV, + ComAtprotoServerGetUserInviteCodes.Handler> + >, + ) { + const nsid = 'com.atproto.server.getUserInviteCodes' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + refreshSession( cfg: ConfigOf>>, ) { diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 9a277fb1dcf..777794edd25 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -1889,6 +1889,60 @@ export const schemaDict = { }, }, }, + ComAtprotoServerGetUserInviteCodes: { + lexicon: 1, + id: 'com.atproto.server.getUserInviteCodes', + defs: { + main: { + type: 'query', + description: 'Get all invite codes for a given user', + parameters: { + type: 'params', + properties: { + includeUsed: { + type: 'boolean', + default: true, + }, + createAvailable: { + type: 'boolean', + default: true, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['codes'], + properties: { + codes: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.server.getUserInviteCodes#invite', + }, + }, + }, + }, + }, + }, + invite: { + type: 'object', + required: ['code', 'available', 'uses'], + properties: { + code: { + type: 'string', + }, + available: { + type: 'integer', + }, + uses: { + type: 'integer', + }, + }, + }, + }, + }, ComAtprotoServerRefreshSession: { lexicon: 1, id: 'com.atproto.server.refreshSession', @@ -4190,6 +4244,7 @@ export const ids = { ComAtprotoServerDeleteSession: 'com.atproto.server.deleteSession', ComAtprotoServerDescribeServer: 'com.atproto.server.describeServer', ComAtprotoServerGetSession: 'com.atproto.server.getSession', + ComAtprotoServerGetUserInviteCodes: 'com.atproto.server.getUserInviteCodes', ComAtprotoServerRefreshSession: 'com.atproto.server.refreshSession', ComAtprotoServerRequestAccountDelete: 'com.atproto.server.requestAccountDelete', diff --git a/packages/pds/src/lexicon/types/com/atproto/server/getUserInviteCodes.ts b/packages/pds/src/lexicon/types/com/atproto/server/getUserInviteCodes.ts new file mode 100644 index 00000000000..871f5c3471c --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/server/getUserInviteCodes.ts @@ -0,0 +1,61 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams { + includeUsed: boolean + createAvailable: boolean +} + +export type InputSchema = undefined + +export interface OutputSchema { + codes: Invite[] + [k: string]: unknown +} + +export type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type Handler = (ctx: { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +}) => Promise | HandlerOutput + +export interface Invite { + code: string + available: number + uses: number + [k: string]: unknown +} + +export function isInvite(v: unknown): v is Invite { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.server.getUserInviteCodes#invite' + ) +} + +export function validateInvite(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.server.getUserInviteCodes#invite', v) +} From 6f76fac4eeb695aae9b234cb3aa9b6e2a5d00ca5 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 4 Apr 2023 12:16:57 -0500 Subject: [PATCH 04/15] transactionally ensure we dont allow duplicate creates --- .../com/atproto/server/getUserInviteCodes.json | 5 ++++- .../api/com/atproto/server/getUserInviteCodes.ts | 16 +++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/lexicons/com/atproto/server/getUserInviteCodes.json b/lexicons/com/atproto/server/getUserInviteCodes.json index 58fe25f5061..288d2aed464 100644 --- a/lexicons/com/atproto/server/getUserInviteCodes.json +++ b/lexicons/com/atproto/server/getUserInviteCodes.json @@ -27,7 +27,10 @@ } } } - } + }, + "errors": [ + "DuplicateCreate" + ] }, "invite": { "type": "object", diff --git a/packages/pds/src/api/com/atproto/server/getUserInviteCodes.ts b/packages/pds/src/api/com/atproto/server/getUserInviteCodes.ts index 0e6ef858381..7c24d12a54f 100644 --- a/packages/pds/src/api/com/atproto/server/getUserInviteCodes.ts +++ b/packages/pds/src/api/com/atproto/server/getUserInviteCodes.ts @@ -2,6 +2,7 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { genInvCodes } from './util' import { sql } from 'kysely' +import { InvalidRequestError } from '@atproto/xrpc-server' export default function (server: Server, ctx: AppContext) { server.com.atproto.server.getUserInviteCodes({ @@ -56,7 +57,20 @@ export default function (server: Server, ctx: AppContext) { createdBy: requester, createdAt: new Date().toISOString(), })) - await ctx.db.db.insertInto('invite_code').values(rows).execute() + await ctx.db.transaction(async (dbTxn) => { + await dbTxn.db.insertInto('invite_code').values(rows).execute() + const forUser = await dbTxn.db + .selectFrom('invite_code') + .where('forUser', '=', requester) + .selectAll() + .execute() + if (forUser.length > userCodes.length + toCreate) { + throw new InvalidRequestError( + 'attempted to create additional codes in another request', + 'DuplicateCreate', + ) + } + }) } } From d8cdf8558c1e6c96e1207a9c833f8df506167832 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 4 Apr 2023 12:41:34 -0500 Subject: [PATCH 05/15] testing & fixes --- .../com/atproto/server/getUserInviteCodes.ts | 21 +++++---- packages/pds/tests/account.test.ts | 44 +++++++++++++++++++ 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/packages/pds/src/api/com/atproto/server/getUserInviteCodes.ts b/packages/pds/src/api/com/atproto/server/getUserInviteCodes.ts index 7c24d12a54f..35af10ade9b 100644 --- a/packages/pds/src/api/com/atproto/server/getUserInviteCodes.ts +++ b/packages/pds/src/api/com/atproto/server/getUserInviteCodes.ts @@ -11,29 +11,34 @@ export default function (server: Server, ctx: AppContext) { const requester = auth.credentials.did const { includeUsed, createAvailable } = params - const [user, userCodes] = await Promise.all([ + const [user, userCodesRes] = await Promise.all([ ctx.db.db .selectFrom('user_account') .where('did', '=', requester) .select('createdAt') .executeTakeFirstOrThrow(), ctx.db.db - .selectFrom('invite_code') - .innerJoin( - 'invite_code_use', - 'invite_code_use.code', - 'invite_code.code', + .with('use_count', (qb) => + qb + .selectFrom('invite_code_use') + .groupBy('code') + .select(['code', sql`count(usedBy)`.as('uses')]), ) + .selectFrom('invite_code') + .leftJoin('use_count', 'use_count.code', 'invite_code.code') .where('forUser', '=', requester) .groupBy('invite_code.code') .select([ 'invite_code.code as code', 'invite_code.availableUses as available', - sql`count(invite_code_use.usedBy)`.as('uses'), + 'use_count.uses as uses', ]) .execute(), ]) - + const userCodes = userCodesRes.map((row) => ({ + ...row, + uses: row.uses ?? 0, + })) const unusedCodes = userCodes.filter((row) => row.available > row.uses) let created: string[] = [] diff --git a/packages/pds/tests/account.test.ts b/packages/pds/tests/account.test.ts index b7953cab15c..a7083b246bc 100644 --- a/packages/pds/tests/account.test.ts +++ b/packages/pds/tests/account.test.ts @@ -9,6 +9,7 @@ import Mail from 'nodemailer/lib/mailer' import { AppContext, Database } from '../src' import * as util from './_util' import { ServerMailer } from '../src/mailer' +import { DAY } from '@atproto/common' const email = 'alice@test.com' const handle = 'alice.test' @@ -45,6 +46,7 @@ describe('account', () => { beforeAll(async () => { const server = await util.runTestServer({ inviteRequired: true, + userInviteInterval: DAY, termsOfServiceUrl: 'https://example.com/tos', privacyPolicyUrl: '/privacy-policy', dbPostgresSchema: 'account', @@ -458,4 +460,46 @@ describe('account', () => { }), ).resolves.toBeDefined() }) + + it('allow users to get available user invites', async () => { + // first pretend account was made 2 days in the past + const twoDaysAgo = new Date(Date.now() - 2 * DAY).toISOString() + await ctx.db.db + .updateTable('user_account') + .set({ createdAt: twoDaysAgo }) + .where('did', '=', did) + .execute() + const res1 = await agent.api.com.atproto.server.getUserInviteCodes() + expect(res1.data.codes.length).toBe(2) + + // now pretend it was made 10 days ago & use both invites + const tenDaysAgo = new Date(Date.now() - 10 * DAY).toISOString() + await ctx.db.db + .updateTable('user_account') + .set({ createdAt: tenDaysAgo }) + .where('did', '=', did) + .execute() + await ctx.db.db + .insertInto('invite_code_use') + .values( + res1.data.codes.map((code) => ({ + code: code.code, + usedBy: 'blah', + usedAt: new Date().toISOString(), + })), + ) + .execute() + + const res2 = await agent.api.com.atproto.server.getUserInviteCodes({ + includeUsed: false, + createAvailable: false, + }) + expect(res2.data.codes.length).toBe(0) + const res3 = await agent.api.com.atproto.server.getUserInviteCodes() + expect(res3.data.codes.length).toBe(7) + const res4 = await agent.api.com.atproto.server.getUserInviteCodes({ + includeUsed: false, + }) + expect(res4.data.codes.length).toBe(5) + }) }) From 0f381308753af329fcc29dda900c09c37d0e23d8 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 4 Apr 2023 12:49:43 -0500 Subject: [PATCH 06/15] clean up & allow admin creation for a particular user --- lexicons/com/atproto/server/createInviteCode.json | 3 ++- lexicons/com/atproto/server/getUserInviteCodes.json | 2 +- packages/api/src/client/lexicons.ts | 9 +++++++++ .../client/types/com/atproto/server/createInviteCode.ts | 1 + .../types/com/atproto/server/getUserInviteCodes.ts | 7 +++++++ .../pds/src/api/com/atproto/server/createInviteCode.ts | 4 ++-- packages/pds/src/lexicon/lexicons.ts | 9 +++++++++ .../lexicon/types/com/atproto/server/createInviteCode.ts | 1 + .../types/com/atproto/server/getUserInviteCodes.ts | 1 + 9 files changed, 33 insertions(+), 4 deletions(-) diff --git a/lexicons/com/atproto/server/createInviteCode.json b/lexicons/com/atproto/server/createInviteCode.json index 44e72bc8533..2d88d8f2d4d 100644 --- a/lexicons/com/atproto/server/createInviteCode.json +++ b/lexicons/com/atproto/server/createInviteCode.json @@ -11,7 +11,8 @@ "type": "object", "required": ["useCount"], "properties": { - "useCount": {"type": "integer"} + "useCount": {"type": "integer"}, + "forUser": {"type": "string", "format": "did"} } } }, diff --git a/lexicons/com/atproto/server/getUserInviteCodes.json b/lexicons/com/atproto/server/getUserInviteCodes.json index 288d2aed464..6c8bdd5c834 100644 --- a/lexicons/com/atproto/server/getUserInviteCodes.json +++ b/lexicons/com/atproto/server/getUserInviteCodes.json @@ -29,7 +29,7 @@ } }, "errors": [ - "DuplicateCreate" + {"name": "DuplicateCreate"} ] }, "invite": { diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 777794edd25..4a75928dcfc 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -1698,6 +1698,10 @@ export const schemaDict = { useCount: { type: 'integer', }, + forUser: { + type: 'string', + format: 'did', + }, }, }, }, @@ -1925,6 +1929,11 @@ export const schemaDict = { }, }, }, + errors: [ + { + name: 'DuplicateCreate', + }, + ], }, invite: { type: 'object', diff --git a/packages/api/src/client/types/com/atproto/server/createInviteCode.ts b/packages/api/src/client/types/com/atproto/server/createInviteCode.ts index c273b17ec2b..8f48d22c1ec 100644 --- a/packages/api/src/client/types/com/atproto/server/createInviteCode.ts +++ b/packages/api/src/client/types/com/atproto/server/createInviteCode.ts @@ -11,6 +11,7 @@ export interface QueryParams {} export interface InputSchema { useCount: number + forUser?: string [k: string]: unknown } diff --git a/packages/api/src/client/types/com/atproto/server/getUserInviteCodes.ts b/packages/api/src/client/types/com/atproto/server/getUserInviteCodes.ts index f1bfaddfd6e..a22f28cc908 100644 --- a/packages/api/src/client/types/com/atproto/server/getUserInviteCodes.ts +++ b/packages/api/src/client/types/com/atproto/server/getUserInviteCodes.ts @@ -29,8 +29,15 @@ export interface Response { data: OutputSchema } +export class DuplicateCreateError extends XRPCError { + constructor(src: XRPCError) { + super(src.status, src.error, src.message) + } +} + export function toKnownErr(e: any) { if (e instanceof XRPCError) { + if (e.error === 'DuplicateCreate') return new DuplicateCreateError(e) } return e } diff --git a/packages/pds/src/api/com/atproto/server/createInviteCode.ts b/packages/pds/src/api/com/atproto/server/createInviteCode.ts index f54a823f257..5867c6d5b91 100644 --- a/packages/pds/src/api/com/atproto/server/createInviteCode.ts +++ b/packages/pds/src/api/com/atproto/server/createInviteCode.ts @@ -6,7 +6,7 @@ export default function (server: Server, ctx: AppContext) { server.com.atproto.server.createInviteCode({ auth: ctx.adminVerifier, handler: async ({ input, req }) => { - const { useCount } = input.body + const { useCount, forUser = 'admin' } = input.body const code = genInvCode(ctx.cfg) @@ -16,7 +16,7 @@ export default function (server: Server, ctx: AppContext) { code: code, availableUses: useCount, disabled: 0, - forUser: 'admin', + forUser, createdBy: 'admin', createdAt: new Date().toISOString(), }) diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 777794edd25..4a75928dcfc 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -1698,6 +1698,10 @@ export const schemaDict = { useCount: { type: 'integer', }, + forUser: { + type: 'string', + format: 'did', + }, }, }, }, @@ -1925,6 +1929,11 @@ export const schemaDict = { }, }, }, + errors: [ + { + name: 'DuplicateCreate', + }, + ], }, invite: { type: 'object', diff --git a/packages/pds/src/lexicon/types/com/atproto/server/createInviteCode.ts b/packages/pds/src/lexicon/types/com/atproto/server/createInviteCode.ts index b5fac25bfcc..f7aeca8d1af 100644 --- a/packages/pds/src/lexicon/types/com/atproto/server/createInviteCode.ts +++ b/packages/pds/src/lexicon/types/com/atproto/server/createInviteCode.ts @@ -12,6 +12,7 @@ export interface QueryParams {} export interface InputSchema { useCount: number + forUser?: string [k: string]: unknown } diff --git a/packages/pds/src/lexicon/types/com/atproto/server/getUserInviteCodes.ts b/packages/pds/src/lexicon/types/com/atproto/server/getUserInviteCodes.ts index 871f5c3471c..887d335ac34 100644 --- a/packages/pds/src/lexicon/types/com/atproto/server/getUserInviteCodes.ts +++ b/packages/pds/src/lexicon/types/com/atproto/server/getUserInviteCodes.ts @@ -30,6 +30,7 @@ export interface HandlerSuccess { export interface HandlerError { status: number message?: string + error?: 'DuplicateCreate' } export type HandlerOutput = HandlerError | HandlerSuccess From feebfee671c740d45a34d58ccb3f288783a83915 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 4 Apr 2023 12:50:58 -0500 Subject: [PATCH 07/15] fix dev-env --- packages/dev-env/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/dev-env/src/index.ts b/packages/dev-env/src/index.ts index 7a410afb9bd..7c5706c63ef 100644 --- a/packages/dev-env/src/index.ts +++ b/packages/dev-env/src/index.ts @@ -101,6 +101,7 @@ export class DevEnvServer { emailNoReplyAddress: 'noreply@blueskyweb.xyz', adminPassword: 'password', inviteRequired: false, + userInviteInterval: null, imgUriSalt: '9dd04221f5755bce5f55f47464c27e1e', imgUriKey: 'f23ecd142835025f42c3db2cf25dd813956c178392760256211f9d315f8ab4d8', From 7d9c61c1209c4efeb53b9ad5596e4d79171c5694 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 4 Apr 2023 16:44:31 -0500 Subject: [PATCH 08/15] user -> accnt & add admin disable codes route --- .../com/atproto/admin/disableInviteCodes.json | 26 ++++++++++ .../atproto/server/getUserInviteCodes.json | 9 ++-- packages/api/src/client/index.ts | 29 +++++++---- packages/api/src/client/lexicons.ts | 48 ++++++++++++++++--- .../com/atproto/admin/disableInviteCodes.ts | 33 +++++++++++++ ...nviteCodes.ts => getAccountInviteCodes.ts} | 5 +- .../com/atproto/admin/disableInviteCodes.ts | 25 ++++++++++ .../pds/src/api/com/atproto/admin/index.ts | 2 + ...nviteCodes.ts => getAccountInviteCodes.ts} | 11 ++++- .../pds/src/api/com/atproto/server/index.ts | 4 +- packages/pds/src/lexicon/index.ts | 19 ++++++-- packages/pds/src/lexicon/lexicons.ts | 48 ++++++++++++++++--- .../com/atproto/admin/disableInviteCodes.ts | 36 ++++++++++++++ ...nviteCodes.ts => getAccountInviteCodes.ts} | 5 +- packages/pds/tests/account.test.ts | 8 ++-- 15 files changed, 268 insertions(+), 40 deletions(-) create mode 100644 lexicons/com/atproto/admin/disableInviteCodes.json create mode 100644 packages/api/src/client/types/com/atproto/admin/disableInviteCodes.ts rename packages/api/src/client/types/com/atproto/server/{getUserInviteCodes.ts => getAccountInviteCodes.ts} (87%) create mode 100644 packages/pds/src/api/com/atproto/admin/disableInviteCodes.ts rename packages/pds/src/api/com/atproto/server/{getUserInviteCodes.ts => getAccountInviteCodes.ts} (92%) create mode 100644 packages/pds/src/lexicon/types/com/atproto/admin/disableInviteCodes.ts rename packages/pds/src/lexicon/types/com/atproto/server/{getUserInviteCodes.ts => getAccountInviteCodes.ts} (88%) diff --git a/lexicons/com/atproto/admin/disableInviteCodes.json b/lexicons/com/atproto/admin/disableInviteCodes.json new file mode 100644 index 00000000000..bfab5479ac6 --- /dev/null +++ b/lexicons/com/atproto/admin/disableInviteCodes.json @@ -0,0 +1,26 @@ +{ + "lexicon": 1, + "id": "com.atproto.admin.disableInviteCodes", + "defs": { + "main": { + "type": "procedure", + "description": "Disable some set of codes and/or all codes associated with a set of users", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "properties": { + "codes": { + "type": "array", + "items": {"type": "string"} + }, + "accounts": { + "type": "array", + "items": {"type": "string"} + } + } + } + } + } + } +} diff --git a/lexicons/com/atproto/server/getUserInviteCodes.json b/lexicons/com/atproto/server/getUserInviteCodes.json index 6c8bdd5c834..96bf71fd95d 100644 --- a/lexicons/com/atproto/server/getUserInviteCodes.json +++ b/lexicons/com/atproto/server/getUserInviteCodes.json @@ -1,10 +1,10 @@ { "lexicon": 1, - "id": "com.atproto.server.getUserInviteCodes", + "id": "com.atproto.server.getAccountInviteCodes", "defs": { "main": { "type": "query", - "description": "Get all invite codes for a given user", + "description": "Get all invite codes for a given account", "parameters": { "type": "params", "properties": { @@ -34,11 +34,12 @@ }, "invite": { "type": "object", - "required": ["code", "available", "uses"], + "required": ["code", "available", "uses", "disabled"], "properties": { "code": { "type": "string" }, "available": { "type": "integer" }, - "uses": { "type": "integer" } + "uses": { "type": "integer" }, + "disabled": { "type": "boolean" } } } } diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index 45344514ff2..aad190bf780 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -8,6 +8,7 @@ import { import { schemas } from './lexicons' import { CID } from 'multiformats/cid' import * as ComAtprotoAdminDefs from './types/com/atproto/admin/defs' +import * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes' import * as ComAtprotoAdminGetModerationAction from './types/com/atproto/admin/getModerationAction' import * as ComAtprotoAdminGetModerationActions from './types/com/atproto/admin/getModerationActions' import * as ComAtprotoAdminGetModerationReport from './types/com/atproto/admin/getModerationReport' @@ -38,7 +39,7 @@ import * as ComAtprotoServerDeleteAccount from './types/com/atproto/server/delet import * as ComAtprotoServerDeleteSession from './types/com/atproto/server/deleteSession' import * as ComAtprotoServerDescribeServer from './types/com/atproto/server/describeServer' import * as ComAtprotoServerGetSession from './types/com/atproto/server/getSession' -import * as ComAtprotoServerGetUserInviteCodes from './types/com/atproto/server/getUserInviteCodes' +import * as ComAtprotoServerGetAccountInviteCodes from './types/com/atproto/server/getAccountInviteCodes' import * as ComAtprotoServerRefreshSession from './types/com/atproto/server/refreshSession' import * as ComAtprotoServerRequestAccountDelete from './types/com/atproto/server/requestAccountDelete' import * as ComAtprotoServerRequestPasswordReset from './types/com/atproto/server/requestPasswordReset' @@ -87,6 +88,7 @@ import * as AppBskyRichtextFacet from './types/app/bsky/richtext/facet' import * as AppBskyUnspeccedGetPopular from './types/app/bsky/unspecced/getPopular' export * as ComAtprotoAdminDefs from './types/com/atproto/admin/defs' +export * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes' export * as ComAtprotoAdminGetModerationAction from './types/com/atproto/admin/getModerationAction' export * as ComAtprotoAdminGetModerationActions from './types/com/atproto/admin/getModerationActions' export * as ComAtprotoAdminGetModerationReport from './types/com/atproto/admin/getModerationReport' @@ -117,7 +119,7 @@ export * as ComAtprotoServerDeleteAccount from './types/com/atproto/server/delet export * as ComAtprotoServerDeleteSession from './types/com/atproto/server/deleteSession' export * as ComAtprotoServerDescribeServer from './types/com/atproto/server/describeServer' export * as ComAtprotoServerGetSession from './types/com/atproto/server/getSession' -export * as ComAtprotoServerGetUserInviteCodes from './types/com/atproto/server/getUserInviteCodes' +export * as ComAtprotoServerGetAccountInviteCodes from './types/com/atproto/server/getAccountInviteCodes' export * as ComAtprotoServerRefreshSession from './types/com/atproto/server/refreshSession' export * as ComAtprotoServerRequestAccountDelete from './types/com/atproto/server/requestAccountDelete' export * as ComAtprotoServerRequestPasswordReset from './types/com/atproto/server/requestPasswordReset' @@ -242,6 +244,17 @@ export class AdminNS { this._service = service } + disableInviteCodes( + data?: ComAtprotoAdminDisableInviteCodes.InputSchema, + opts?: ComAtprotoAdminDisableInviteCodes.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.admin.disableInviteCodes', opts?.qp, data, opts) + .catch((e) => { + throw ComAtprotoAdminDisableInviteCodes.toKnownErr(e) + }) + } + getModerationAction( params?: ComAtprotoAdminGetModerationAction.QueryParams, opts?: ComAtprotoAdminGetModerationAction.CallOptions, @@ -582,14 +595,14 @@ export class ServerNS { }) } - getUserInviteCodes( - params?: ComAtprotoServerGetUserInviteCodes.QueryParams, - opts?: ComAtprotoServerGetUserInviteCodes.CallOptions, - ): Promise { + getAccountInviteCodes( + params?: ComAtprotoServerGetAccountInviteCodes.QueryParams, + opts?: ComAtprotoServerGetAccountInviteCodes.CallOptions, + ): Promise { return this._service.xrpc - .call('com.atproto.server.getUserInviteCodes', params, undefined, opts) + .call('com.atproto.server.getAccountInviteCodes', params, undefined, opts) .catch((e) => { - throw ComAtprotoServerGetUserInviteCodes.toKnownErr(e) + throw ComAtprotoServerGetAccountInviteCodes.toKnownErr(e) }) } diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 4a75928dcfc..4a6a328d409 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -530,6 +530,37 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminDisableInviteCodes: { + lexicon: 1, + id: 'com.atproto.admin.disableInviteCodes', + defs: { + main: { + type: 'procedure', + description: + 'Disable some set of codes and/or all codes associated with a set of users', + input: { + encoding: 'application/json', + schema: { + type: 'object', + properties: { + codes: { + type: 'array', + items: { + type: 'string', + }, + }, + accounts: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, ComAtprotoAdminGetModerationAction: { lexicon: 1, id: 'com.atproto.admin.getModerationAction', @@ -1893,13 +1924,13 @@ export const schemaDict = { }, }, }, - ComAtprotoServerGetUserInviteCodes: { + ComAtprotoServerGetAccountInviteCodes: { lexicon: 1, - id: 'com.atproto.server.getUserInviteCodes', + id: 'com.atproto.server.getAccountInviteCodes', defs: { main: { type: 'query', - description: 'Get all invite codes for a given user', + description: 'Get all invite codes for a given account', parameters: { type: 'params', properties: { @@ -1923,7 +1954,7 @@ export const schemaDict = { type: 'array', items: { type: 'ref', - ref: 'lex:com.atproto.server.getUserInviteCodes#invite', + ref: 'lex:com.atproto.server.getAccountInviteCodes#invite', }, }, }, @@ -1937,7 +1968,7 @@ export const schemaDict = { }, invite: { type: 'object', - required: ['code', 'available', 'uses'], + required: ['code', 'available', 'uses', 'disabled'], properties: { code: { type: 'string', @@ -1948,6 +1979,9 @@ export const schemaDict = { uses: { type: 'integer', }, + disabled: { + type: 'boolean', + }, }, }, }, @@ -4221,6 +4255,7 @@ export const schemas: LexiconDoc[] = Object.values(schemaDict) as LexiconDoc[] export const lexicons: Lexicons = new Lexicons(schemas) export const ids = { ComAtprotoAdminDefs: 'com.atproto.admin.defs', + ComAtprotoAdminDisableInviteCodes: 'com.atproto.admin.disableInviteCodes', ComAtprotoAdminGetModerationAction: 'com.atproto.admin.getModerationAction', ComAtprotoAdminGetModerationActions: 'com.atproto.admin.getModerationActions', ComAtprotoAdminGetModerationReport: 'com.atproto.admin.getModerationReport', @@ -4253,7 +4288,8 @@ export const ids = { ComAtprotoServerDeleteSession: 'com.atproto.server.deleteSession', ComAtprotoServerDescribeServer: 'com.atproto.server.describeServer', ComAtprotoServerGetSession: 'com.atproto.server.getSession', - ComAtprotoServerGetUserInviteCodes: 'com.atproto.server.getUserInviteCodes', + ComAtprotoServerGetAccountInviteCodes: + 'com.atproto.server.getAccountInviteCodes', ComAtprotoServerRefreshSession: 'com.atproto.server.refreshSession', ComAtprotoServerRequestAccountDelete: 'com.atproto.server.requestAccountDelete', diff --git a/packages/api/src/client/types/com/atproto/admin/disableInviteCodes.ts b/packages/api/src/client/types/com/atproto/admin/disableInviteCodes.ts new file mode 100644 index 00000000000..7ceed97f912 --- /dev/null +++ b/packages/api/src/client/types/com/atproto/admin/disableInviteCodes.ts @@ -0,0 +1,33 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { Headers, XRPCError } from '@atproto/xrpc' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { isObj, hasProp } from '../../../../util' +import { lexicons } from '../../../../lexicons' +import { CID } from 'multiformats/cid' + +export interface QueryParams {} + +export interface InputSchema { + codes?: string[] + accounts?: string[] + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers + qp?: QueryParams + encoding: 'application/json' +} + +export interface Response { + success: boolean + headers: Headers +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/packages/api/src/client/types/com/atproto/server/getUserInviteCodes.ts b/packages/api/src/client/types/com/atproto/server/getAccountInviteCodes.ts similarity index 87% rename from packages/api/src/client/types/com/atproto/server/getUserInviteCodes.ts rename to packages/api/src/client/types/com/atproto/server/getAccountInviteCodes.ts index a22f28cc908..59fba85a4be 100644 --- a/packages/api/src/client/types/com/atproto/server/getUserInviteCodes.ts +++ b/packages/api/src/client/types/com/atproto/server/getAccountInviteCodes.ts @@ -46,6 +46,7 @@ export interface Invite { code: string available: number uses: number + disabled: boolean [k: string]: unknown } @@ -53,10 +54,10 @@ export function isInvite(v: unknown): v is Invite { return ( isObj(v) && hasProp(v, '$type') && - v.$type === 'com.atproto.server.getUserInviteCodes#invite' + v.$type === 'com.atproto.server.getAccountInviteCodes#invite' ) } export function validateInvite(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.server.getUserInviteCodes#invite', v) + return lexicons.validate('com.atproto.server.getAccountInviteCodes#invite', v) } diff --git a/packages/pds/src/api/com/atproto/admin/disableInviteCodes.ts b/packages/pds/src/api/com/atproto/admin/disableInviteCodes.ts new file mode 100644 index 00000000000..3faef45dc33 --- /dev/null +++ b/packages/pds/src/api/com/atproto/admin/disableInviteCodes.ts @@ -0,0 +1,25 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.admin.disableInviteCodes({ + auth: ctx.adminVerifier, + handler: async ({ input }) => { + const { codes = [], accounts = [] } = input.body + if (codes.length > 0) { + await ctx.db.db + .updateTable('invite_code') + .set({ disabled: 1 }) + .where('code', 'in', codes) + .execute() + } + if (accounts.length > 0) { + await ctx.db.db + .updateTable('invite_code') + .set({ disabled: 1 }) + .where('forUser', 'in', accounts) + .execute() + } + }, + }) +} diff --git a/packages/pds/src/api/com/atproto/admin/index.ts b/packages/pds/src/api/com/atproto/admin/index.ts index 35e2670e3cb..a75dc4d1a45 100644 --- a/packages/pds/src/api/com/atproto/admin/index.ts +++ b/packages/pds/src/api/com/atproto/admin/index.ts @@ -10,6 +10,7 @@ import getModerationAction from './getModerationAction' import getModerationActions from './getModerationActions' import getModerationReport from './getModerationReport' import getModerationReports from './getModerationReports' +import disableInviteCodes from './disableInviteCodes' export default function (server: Server, ctx: AppContext) { resolveModerationReports(server, ctx) @@ -22,4 +23,5 @@ export default function (server: Server, ctx: AppContext) { getModerationActions(server, ctx) getModerationReport(server, ctx) getModerationReports(server, ctx) + disableInviteCodes(server, ctx) } diff --git a/packages/pds/src/api/com/atproto/server/getUserInviteCodes.ts b/packages/pds/src/api/com/atproto/server/getAccountInviteCodes.ts similarity index 92% rename from packages/pds/src/api/com/atproto/server/getUserInviteCodes.ts rename to packages/pds/src/api/com/atproto/server/getAccountInviteCodes.ts index 35af10ade9b..718b1a9ea49 100644 --- a/packages/pds/src/api/com/atproto/server/getUserInviteCodes.ts +++ b/packages/pds/src/api/com/atproto/server/getAccountInviteCodes.ts @@ -5,7 +5,7 @@ import { sql } from 'kysely' import { InvalidRequestError } from '@atproto/xrpc-server' export default function (server: Server, ctx: AppContext) { - server.com.atproto.server.getUserInviteCodes({ + server.com.atproto.server.getAccountInviteCodes({ auth: ctx.accessVerifierCheckTakedown, handler: async ({ params, auth }) => { const requester = auth.credentials.did @@ -31,6 +31,7 @@ export default function (server: Server, ctx: AppContext) { .select([ 'invite_code.code as code', 'invite_code.availableUses as available', + 'invite_code.disabled as disabled', 'use_count.uses as uses', ]) .execute(), @@ -38,6 +39,7 @@ export default function (server: Server, ctx: AppContext) { const userCodes = userCodesRes.map((row) => ({ ...row, uses: row.uses ?? 0, + disabled: row.disabled === 1, })) const unusedCodes = userCodes.filter((row) => row.available > row.uses) @@ -81,7 +83,12 @@ export default function (server: Server, ctx: AppContext) { const toReturn = [ ...(includeUsed ? userCodes : unusedCodes), - ...created.map((code) => ({ code: code, available: 1, uses: 0 })), + ...created.map((code) => ({ + code: code, + available: 1, + uses: 0, + disabled: false, + })), ] return { diff --git a/packages/pds/src/api/com/atproto/server/index.ts b/packages/pds/src/api/com/atproto/server/index.ts index e8227666318..5de19a1f40d 100644 --- a/packages/pds/src/api/com/atproto/server/index.ts +++ b/packages/pds/src/api/com/atproto/server/index.ts @@ -5,7 +5,7 @@ import describeServer from './describeServer' import createAccount from './createAccount' import createInviteCode from './createInviteCode' -import getUserInviteCodes from './getUserInviteCodes' +import getAccountInviteCodes from './getAccountInviteCodes' import requestDelete from './requestAccountDelete' import deleteAccount from './deleteAccount' @@ -22,7 +22,7 @@ export default function (server: Server, ctx: AppContext) { describeServer(server, ctx) createAccount(server, ctx) createInviteCode(server, ctx) - getUserInviteCodes(server, ctx) + getAccountInviteCodes(server, ctx) requestDelete(server, ctx) deleteAccount(server, ctx) requestPasswordReset(server, ctx) diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index 3a64fdaeb63..fca45989d3a 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -9,6 +9,7 @@ import { StreamAuthVerifier, } from '@atproto/xrpc-server' import { schemas } from './lexicons' +import * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes' import * as ComAtprotoAdminGetModerationAction from './types/com/atproto/admin/getModerationAction' import * as ComAtprotoAdminGetModerationActions from './types/com/atproto/admin/getModerationActions' import * as ComAtprotoAdminGetModerationReport from './types/com/atproto/admin/getModerationReport' @@ -37,7 +38,7 @@ import * as ComAtprotoServerDeleteAccount from './types/com/atproto/server/delet import * as ComAtprotoServerDeleteSession from './types/com/atproto/server/deleteSession' import * as ComAtprotoServerDescribeServer from './types/com/atproto/server/describeServer' import * as ComAtprotoServerGetSession from './types/com/atproto/server/getSession' -import * as ComAtprotoServerGetUserInviteCodes from './types/com/atproto/server/getUserInviteCodes' +import * as ComAtprotoServerGetAccountInviteCodes from './types/com/atproto/server/getAccountInviteCodes' import * as ComAtprotoServerRefreshSession from './types/com/atproto/server/refreshSession' import * as ComAtprotoServerRequestAccountDelete from './types/com/atproto/server/requestAccountDelete' import * as ComAtprotoServerRequestPasswordReset from './types/com/atproto/server/requestPasswordReset' @@ -136,6 +137,16 @@ export class AdminNS { this._server = server } + disableInviteCodes( + cfg: ConfigOf< + AV, + ComAtprotoAdminDisableInviteCodes.Handler> + >, + ) { + const nsid = 'com.atproto.admin.disableInviteCodes' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + getModerationAction( cfg: ConfigOf< AV, @@ -391,13 +402,13 @@ export class ServerNS { return this._server.xrpc.method(nsid, cfg) } - getUserInviteCodes( + getAccountInviteCodes( cfg: ConfigOf< AV, - ComAtprotoServerGetUserInviteCodes.Handler> + ComAtprotoServerGetAccountInviteCodes.Handler> >, ) { - const nsid = 'com.atproto.server.getUserInviteCodes' // @ts-ignore + const nsid = 'com.atproto.server.getAccountInviteCodes' // @ts-ignore return this._server.xrpc.method(nsid, cfg) } diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 4a75928dcfc..4a6a328d409 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -530,6 +530,37 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminDisableInviteCodes: { + lexicon: 1, + id: 'com.atproto.admin.disableInviteCodes', + defs: { + main: { + type: 'procedure', + description: + 'Disable some set of codes and/or all codes associated with a set of users', + input: { + encoding: 'application/json', + schema: { + type: 'object', + properties: { + codes: { + type: 'array', + items: { + type: 'string', + }, + }, + accounts: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, ComAtprotoAdminGetModerationAction: { lexicon: 1, id: 'com.atproto.admin.getModerationAction', @@ -1893,13 +1924,13 @@ export const schemaDict = { }, }, }, - ComAtprotoServerGetUserInviteCodes: { + ComAtprotoServerGetAccountInviteCodes: { lexicon: 1, - id: 'com.atproto.server.getUserInviteCodes', + id: 'com.atproto.server.getAccountInviteCodes', defs: { main: { type: 'query', - description: 'Get all invite codes for a given user', + description: 'Get all invite codes for a given account', parameters: { type: 'params', properties: { @@ -1923,7 +1954,7 @@ export const schemaDict = { type: 'array', items: { type: 'ref', - ref: 'lex:com.atproto.server.getUserInviteCodes#invite', + ref: 'lex:com.atproto.server.getAccountInviteCodes#invite', }, }, }, @@ -1937,7 +1968,7 @@ export const schemaDict = { }, invite: { type: 'object', - required: ['code', 'available', 'uses'], + required: ['code', 'available', 'uses', 'disabled'], properties: { code: { type: 'string', @@ -1948,6 +1979,9 @@ export const schemaDict = { uses: { type: 'integer', }, + disabled: { + type: 'boolean', + }, }, }, }, @@ -4221,6 +4255,7 @@ export const schemas: LexiconDoc[] = Object.values(schemaDict) as LexiconDoc[] export const lexicons: Lexicons = new Lexicons(schemas) export const ids = { ComAtprotoAdminDefs: 'com.atproto.admin.defs', + ComAtprotoAdminDisableInviteCodes: 'com.atproto.admin.disableInviteCodes', ComAtprotoAdminGetModerationAction: 'com.atproto.admin.getModerationAction', ComAtprotoAdminGetModerationActions: 'com.atproto.admin.getModerationActions', ComAtprotoAdminGetModerationReport: 'com.atproto.admin.getModerationReport', @@ -4253,7 +4288,8 @@ export const ids = { ComAtprotoServerDeleteSession: 'com.atproto.server.deleteSession', ComAtprotoServerDescribeServer: 'com.atproto.server.describeServer', ComAtprotoServerGetSession: 'com.atproto.server.getSession', - ComAtprotoServerGetUserInviteCodes: 'com.atproto.server.getUserInviteCodes', + ComAtprotoServerGetAccountInviteCodes: + 'com.atproto.server.getAccountInviteCodes', ComAtprotoServerRefreshSession: 'com.atproto.server.refreshSession', ComAtprotoServerRequestAccountDelete: 'com.atproto.server.requestAccountDelete', diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/disableInviteCodes.ts b/packages/pds/src/lexicon/types/com/atproto/admin/disableInviteCodes.ts new file mode 100644 index 00000000000..2e9d326afe9 --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/admin/disableInviteCodes.ts @@ -0,0 +1,36 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export interface InputSchema { + codes?: string[] + accounts?: string[] + [k: string]: unknown +} + +export interface HandlerInput { + encoding: 'application/json' + body: InputSchema +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | void +export type Handler = (ctx: { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +}) => Promise | HandlerOutput diff --git a/packages/pds/src/lexicon/types/com/atproto/server/getUserInviteCodes.ts b/packages/pds/src/lexicon/types/com/atproto/server/getAccountInviteCodes.ts similarity index 88% rename from packages/pds/src/lexicon/types/com/atproto/server/getUserInviteCodes.ts rename to packages/pds/src/lexicon/types/com/atproto/server/getAccountInviteCodes.ts index 887d335ac34..e701971c4ae 100644 --- a/packages/pds/src/lexicon/types/com/atproto/server/getUserInviteCodes.ts +++ b/packages/pds/src/lexicon/types/com/atproto/server/getAccountInviteCodes.ts @@ -46,6 +46,7 @@ export interface Invite { code: string available: number uses: number + disabled: boolean [k: string]: unknown } @@ -53,10 +54,10 @@ export function isInvite(v: unknown): v is Invite { return ( isObj(v) && hasProp(v, '$type') && - v.$type === 'com.atproto.server.getUserInviteCodes#invite' + v.$type === 'com.atproto.server.getAccountInviteCodes#invite' ) } export function validateInvite(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.server.getUserInviteCodes#invite', v) + return lexicons.validate('com.atproto.server.getAccountInviteCodes#invite', v) } diff --git a/packages/pds/tests/account.test.ts b/packages/pds/tests/account.test.ts index a7083b246bc..f8ccc2db404 100644 --- a/packages/pds/tests/account.test.ts +++ b/packages/pds/tests/account.test.ts @@ -469,7 +469,7 @@ describe('account', () => { .set({ createdAt: twoDaysAgo }) .where('did', '=', did) .execute() - const res1 = await agent.api.com.atproto.server.getUserInviteCodes() + const res1 = await agent.api.com.atproto.server.getAccountInviteCodes() expect(res1.data.codes.length).toBe(2) // now pretend it was made 10 days ago & use both invites @@ -490,14 +490,14 @@ describe('account', () => { ) .execute() - const res2 = await agent.api.com.atproto.server.getUserInviteCodes({ + const res2 = await agent.api.com.atproto.server.getAccountInviteCodes({ includeUsed: false, createAvailable: false, }) expect(res2.data.codes.length).toBe(0) - const res3 = await agent.api.com.atproto.server.getUserInviteCodes() + const res3 = await agent.api.com.atproto.server.getAccountInviteCodes() expect(res3.data.codes.length).toBe(7) - const res4 = await agent.api.com.atproto.server.getUserInviteCodes({ + const res4 = await agent.api.com.atproto.server.getAccountInviteCodes({ includeUsed: false, }) expect(res4.data.codes.length).toBe(5) From 28b9a2645c51efb977674bfc2961afdce23bfc82 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 4 Apr 2023 16:56:04 -0500 Subject: [PATCH 09/15] proposed admin inv schemas --- .../com/atproto/admin/getInviteCodeUsage.json | 28 +++++++++++ .../com/atproto/admin/getInviteCodes.json | 47 +++++++++++++++++++ .../com/atproto/admin/disableInviteCodes.ts | 6 +++ 3 files changed, 81 insertions(+) create mode 100644 lexicons/com/atproto/admin/getInviteCodeUsage.json create mode 100644 lexicons/com/atproto/admin/getInviteCodes.json diff --git a/lexicons/com/atproto/admin/getInviteCodeUsage.json b/lexicons/com/atproto/admin/getInviteCodeUsage.json new file mode 100644 index 00000000000..9a1500203ab --- /dev/null +++ b/lexicons/com/atproto/admin/getInviteCodeUsage.json @@ -0,0 +1,28 @@ +{ + "lexicon": 1, + "id": "com.atproto.admin.getInviteCodeUsage", + "defs": { + "main": { + "type": "query", + "description": "High level stats about invite code usage", + "output": { + "encoding": "application/json", + "schema": { + "total": { "type": "ref", "ref": "#codesDetail" }, + "user": { "type": "ref", "ref": "#codesDetail" }, + "admin": { "type": "ref", "ref": "#codesDetail" } + } + } + }, + "codesDetail": { + "type": "object", + "required": ["count", "available", "used", "disabled"], + "properties": { + "count": {"type": "integer"}, + "available": {"type": "integer"}, + "used": {"type": "integer"}, + "disabled": {"type": "integer"} + } + } + } +} diff --git a/lexicons/com/atproto/admin/getInviteCodes.json b/lexicons/com/atproto/admin/getInviteCodes.json new file mode 100644 index 00000000000..e6b3fca84c9 --- /dev/null +++ b/lexicons/com/atproto/admin/getInviteCodes.json @@ -0,0 +1,47 @@ +{ + "lexicon": 1, + "id": "com.atproto.admin.getInviteCode", + "defs": { + "main": { + "type": "query", + "description": "Admin view of invite codes", + "parameters": { + "type": "params", + "properties": { + "sort": { + "type": "string", + "knownValues": [ + "recent", + "available" + ], + "default": "recent" + }, + "cursor": {"type": "string"} + } + }, + "output": { + "encoding": "application/json", + "schema": { + "cursor": {"type": "string"}, + "total": { "type": "ref", "ref": "#codesDetail" }, + "user": { "type": "ref", "ref": "#codesDetail" }, + "admin": { "type": "ref", "ref": "#codesDetail" } + } + } + }, + "codeDetail": { + "type": "object", + "required": ["code", "count", "available", "used", "disabled"], + "properties": { + "code": {"type": "string"}, + "forAccount": {"type": "string"}, + "usedBy": { + "type": "array", + "items": {"type": "string"} + }, + "total": {"type": "integer"}, + "disabled": {"type": "boolean"} + } + } + } +} diff --git a/packages/pds/src/api/com/atproto/admin/disableInviteCodes.ts b/packages/pds/src/api/com/atproto/admin/disableInviteCodes.ts index 3faef45dc33..3da853ee96e 100644 --- a/packages/pds/src/api/com/atproto/admin/disableInviteCodes.ts +++ b/packages/pds/src/api/com/atproto/admin/disableInviteCodes.ts @@ -1,6 +1,12 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +type Total = { + count: number + open: number + used: number +} + export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.disableInviteCodes({ auth: ctx.adminVerifier, From 3da72c27b2be1fc534ea1472975a306a52d23d24 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 4 Apr 2023 18:16:56 -0500 Subject: [PATCH 10/15] more admin routes for inv codes --- .../com/atproto/admin/getInviteCodeUsage.json | 10 +- .../com/atproto/admin/getInviteCodes.json | 40 +++- ...eCodes.json => getAccountInviteCodes.json} | 0 packages/api/src/client/index.ts | 52 +++-- packages/api/src/client/lexicons.ts | 206 +++++++++++++++--- .../com/atproto/admin/getInviteCodeUsage.ts | 58 +++++ .../types/com/atproto/admin/getInviteCodes.ts | 79 +++++++ .../com/atproto/admin/disableInviteCodes.ts | 6 - .../com/atproto/admin/getInviteCodeUsage.ts | 73 +++++++ .../api/com/atproto/admin/getInviteCodes.ts | 137 ++++++++++++ .../pds/src/api/com/atproto/admin/index.ts | 4 + packages/pds/src/lexicon/index.ts | 35 ++- packages/pds/src/lexicon/lexicons.ts | 206 +++++++++++++++--- .../com/atproto/admin/getInviteCodeUsage.ts | 64 ++++++ .../types/com/atproto/admin/getInviteCodes.ts | 85 ++++++++ 15 files changed, 957 insertions(+), 98 deletions(-) rename lexicons/com/atproto/server/{getUserInviteCodes.json => getAccountInviteCodes.json} (100%) create mode 100644 packages/api/src/client/types/com/atproto/admin/getInviteCodeUsage.ts create mode 100644 packages/api/src/client/types/com/atproto/admin/getInviteCodes.ts create mode 100644 packages/pds/src/api/com/atproto/admin/getInviteCodeUsage.ts create mode 100644 packages/pds/src/api/com/atproto/admin/getInviteCodes.ts create mode 100644 packages/pds/src/lexicon/types/com/atproto/admin/getInviteCodeUsage.ts create mode 100644 packages/pds/src/lexicon/types/com/atproto/admin/getInviteCodes.ts diff --git a/lexicons/com/atproto/admin/getInviteCodeUsage.json b/lexicons/com/atproto/admin/getInviteCodeUsage.json index 9a1500203ab..033855898fd 100644 --- a/lexicons/com/atproto/admin/getInviteCodeUsage.json +++ b/lexicons/com/atproto/admin/getInviteCodeUsage.json @@ -8,9 +8,13 @@ "output": { "encoding": "application/json", "schema": { - "total": { "type": "ref", "ref": "#codesDetail" }, - "user": { "type": "ref", "ref": "#codesDetail" }, - "admin": { "type": "ref", "ref": "#codesDetail" } + "type": "object", + "required": ["subject", "follows"], + "properties": { + "total": { "type": "ref", "ref": "#codesDetail" }, + "user": { "type": "ref", "ref": "#codesDetail" }, + "admin": { "type": "ref", "ref": "#codesDetail" } + } } } }, diff --git a/lexicons/com/atproto/admin/getInviteCodes.json b/lexicons/com/atproto/admin/getInviteCodes.json index e6b3fca84c9..0b710e11b15 100644 --- a/lexicons/com/atproto/admin/getInviteCodes.json +++ b/lexicons/com/atproto/admin/getInviteCodes.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "com.atproto.admin.getInviteCode", + "id": "com.atproto.admin.getInviteCodes", "defs": { "main": { "type": "query", @@ -12,35 +12,51 @@ "type": "string", "knownValues": [ "recent", - "available" + "usage" ], "default": "recent" }, + "limit": {"type": "integer", "minimum": 1, "maximum": 500, "default": 100}, "cursor": {"type": "string"} } }, "output": { "encoding": "application/json", "schema": { - "cursor": {"type": "string"}, - "total": { "type": "ref", "ref": "#codesDetail" }, - "user": { "type": "ref", "ref": "#codesDetail" }, - "admin": { "type": "ref", "ref": "#codesDetail" } + "type": "object", + "required": ["cursor", "codes"], + "properties": { + "cursor": {"type": "string"}, + "codes": { + "type": "array", + "items": {"type": "ref", "ref": "#codeDetail"} + } + } } } }, "codeDetail": { "type": "object", - "required": ["code", "count", "available", "used", "disabled"], + "required": ["code", "available", "disabled", "forAccount", "createdBy", "createdAt", "uses"], "properties": { "code": {"type": "string"}, + "available": {"type": "integer"}, + "disabled": {"type": "boolean"}, "forAccount": {"type": "string"}, - "usedBy": { + "createdBy": {"type": "string"}, + "createdAt": {"type": "string"}, + "uses": { "type": "array", - "items": {"type": "string"} - }, - "total": {"type": "integer"}, - "disabled": {"type": "boolean"} + "items": {"type": "ref", "ref": "#codeUse"} + } + } + }, + "codeUse": { + "type": "object", + "required": ["usedBy", "usedAt"], + "properties": { + "usedBy": {"type": "string"}, + "usedAt": {"type": "string"} } } } diff --git a/lexicons/com/atproto/server/getUserInviteCodes.json b/lexicons/com/atproto/server/getAccountInviteCodes.json similarity index 100% rename from lexicons/com/atproto/server/getUserInviteCodes.json rename to lexicons/com/atproto/server/getAccountInviteCodes.json diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index aad190bf780..9432cea1987 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -9,6 +9,8 @@ import { schemas } from './lexicons' import { CID } from 'multiformats/cid' import * as ComAtprotoAdminDefs from './types/com/atproto/admin/defs' import * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes' +import * as ComAtprotoAdminGetInviteCodeUsage from './types/com/atproto/admin/getInviteCodeUsage' +import * as ComAtprotoAdminGetInviteCodes from './types/com/atproto/admin/getInviteCodes' import * as ComAtprotoAdminGetModerationAction from './types/com/atproto/admin/getModerationAction' import * as ComAtprotoAdminGetModerationActions from './types/com/atproto/admin/getModerationActions' import * as ComAtprotoAdminGetModerationReport from './types/com/atproto/admin/getModerationReport' @@ -38,8 +40,8 @@ import * as ComAtprotoServerCreateSession from './types/com/atproto/server/creat import * as ComAtprotoServerDeleteAccount from './types/com/atproto/server/deleteAccount' import * as ComAtprotoServerDeleteSession from './types/com/atproto/server/deleteSession' import * as ComAtprotoServerDescribeServer from './types/com/atproto/server/describeServer' -import * as ComAtprotoServerGetSession from './types/com/atproto/server/getSession' import * as ComAtprotoServerGetAccountInviteCodes from './types/com/atproto/server/getAccountInviteCodes' +import * as ComAtprotoServerGetSession from './types/com/atproto/server/getSession' import * as ComAtprotoServerRefreshSession from './types/com/atproto/server/refreshSession' import * as ComAtprotoServerRequestAccountDelete from './types/com/atproto/server/requestAccountDelete' import * as ComAtprotoServerRequestPasswordReset from './types/com/atproto/server/requestPasswordReset' @@ -89,6 +91,8 @@ import * as AppBskyUnspeccedGetPopular from './types/app/bsky/unspecced/getPopul export * as ComAtprotoAdminDefs from './types/com/atproto/admin/defs' export * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes' +export * as ComAtprotoAdminGetInviteCodeUsage from './types/com/atproto/admin/getInviteCodeUsage' +export * as ComAtprotoAdminGetInviteCodes from './types/com/atproto/admin/getInviteCodes' export * as ComAtprotoAdminGetModerationAction from './types/com/atproto/admin/getModerationAction' export * as ComAtprotoAdminGetModerationActions from './types/com/atproto/admin/getModerationActions' export * as ComAtprotoAdminGetModerationReport from './types/com/atproto/admin/getModerationReport' @@ -118,8 +122,8 @@ export * as ComAtprotoServerCreateSession from './types/com/atproto/server/creat export * as ComAtprotoServerDeleteAccount from './types/com/atproto/server/deleteAccount' export * as ComAtprotoServerDeleteSession from './types/com/atproto/server/deleteSession' export * as ComAtprotoServerDescribeServer from './types/com/atproto/server/describeServer' -export * as ComAtprotoServerGetSession from './types/com/atproto/server/getSession' export * as ComAtprotoServerGetAccountInviteCodes from './types/com/atproto/server/getAccountInviteCodes' +export * as ComAtprotoServerGetSession from './types/com/atproto/server/getSession' export * as ComAtprotoServerRefreshSession from './types/com/atproto/server/refreshSession' export * as ComAtprotoServerRequestAccountDelete from './types/com/atproto/server/requestAccountDelete' export * as ComAtprotoServerRequestPasswordReset from './types/com/atproto/server/requestPasswordReset' @@ -255,6 +259,28 @@ export class AdminNS { }) } + getInviteCodeUsage( + params?: ComAtprotoAdminGetInviteCodeUsage.QueryParams, + opts?: ComAtprotoAdminGetInviteCodeUsage.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.admin.getInviteCodeUsage', params, undefined, opts) + .catch((e) => { + throw ComAtprotoAdminGetInviteCodeUsage.toKnownErr(e) + }) + } + + getInviteCodes( + params?: ComAtprotoAdminGetInviteCodes.QueryParams, + opts?: ComAtprotoAdminGetInviteCodes.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.admin.getInviteCodes', params, undefined, opts) + .catch((e) => { + throw ComAtprotoAdminGetInviteCodes.toKnownErr(e) + }) + } + getModerationAction( params?: ComAtprotoAdminGetModerationAction.QueryParams, opts?: ComAtprotoAdminGetModerationAction.CallOptions, @@ -584,17 +610,6 @@ export class ServerNS { }) } - getSession( - params?: ComAtprotoServerGetSession.QueryParams, - opts?: ComAtprotoServerGetSession.CallOptions, - ): Promise { - return this._service.xrpc - .call('com.atproto.server.getSession', params, undefined, opts) - .catch((e) => { - throw ComAtprotoServerGetSession.toKnownErr(e) - }) - } - getAccountInviteCodes( params?: ComAtprotoServerGetAccountInviteCodes.QueryParams, opts?: ComAtprotoServerGetAccountInviteCodes.CallOptions, @@ -606,6 +621,17 @@ export class ServerNS { }) } + getSession( + params?: ComAtprotoServerGetSession.QueryParams, + opts?: ComAtprotoServerGetSession.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.server.getSession', params, undefined, opts) + .catch((e) => { + throw ComAtprotoServerGetSession.toKnownErr(e) + }) + } + refreshSession( data?: ComAtprotoServerRefreshSession.InputSchema, opts?: ComAtprotoServerRefreshSession.CallOptions, diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 4a6a328d409..a6f068512de 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -561,6 +561,154 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminGetInviteCodeUsage: { + lexicon: 1, + id: 'com.atproto.admin.getInviteCodeUsage', + defs: { + main: { + type: 'query', + description: 'High level stats about invite code usage', + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['subject', 'follows'], + properties: { + total: { + type: 'ref', + ref: 'lex:com.atproto.admin.getInviteCodeUsage#codesDetail', + }, + user: { + type: 'ref', + ref: 'lex:com.atproto.admin.getInviteCodeUsage#codesDetail', + }, + admin: { + type: 'ref', + ref: 'lex:com.atproto.admin.getInviteCodeUsage#codesDetail', + }, + }, + }, + }, + }, + codesDetail: { + type: 'object', + required: ['count', 'available', 'used', 'disabled'], + properties: { + count: { + type: 'integer', + }, + available: { + type: 'integer', + }, + used: { + type: 'integer', + }, + disabled: { + type: 'integer', + }, + }, + }, + }, + }, + ComAtprotoAdminGetInviteCodes: { + lexicon: 1, + id: 'com.atproto.admin.getInviteCodes', + defs: { + main: { + type: 'query', + description: 'Admin view of invite codes', + parameters: { + type: 'params', + properties: { + sort: { + type: 'string', + knownValues: ['recent', 'usage'], + default: 'recent', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 1000, + default: 500, + }, + cursor: { + type: 'string', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['cursor', 'codes'], + properties: { + cursor: { + type: 'string', + }, + codes: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.admin.getInviteCodes#codeDetail', + }, + }, + }, + }, + }, + }, + codeDetail: { + type: 'object', + required: [ + 'code', + 'available', + 'disabled', + 'forAccount', + 'createdBy', + 'createdAt', + 'uses', + ], + properties: { + code: { + type: 'string', + }, + available: { + type: 'integer', + }, + disabled: { + type: 'boolean', + }, + forAccount: { + type: 'string', + }, + createdBy: { + type: 'string', + }, + createdAt: { + type: 'string', + }, + uses: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.admin.getInviteCodes#codeUse', + }, + }, + }, + }, + codeUse: { + type: 'object', + required: ['usedBy', 'usedAt'], + properties: { + usedBy: { + type: 'string', + }, + usedAt: { + type: 'string', + }, + }, + }, + }, + }, ComAtprotoAdminGetModerationAction: { lexicon: 1, id: 'com.atproto.admin.getModerationAction', @@ -1897,33 +2045,6 @@ export const schemaDict = { }, }, }, - ComAtprotoServerGetSession: { - lexicon: 1, - id: 'com.atproto.server.getSession', - defs: { - main: { - type: 'query', - description: 'Get information about the current session.', - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['handle', 'did'], - properties: { - handle: { - type: 'string', - format: 'handle', - }, - did: { - type: 'string', - format: 'did', - }, - }, - }, - }, - }, - }, - }, ComAtprotoServerGetAccountInviteCodes: { lexicon: 1, id: 'com.atproto.server.getAccountInviteCodes', @@ -1986,6 +2107,33 @@ export const schemaDict = { }, }, }, + ComAtprotoServerGetSession: { + lexicon: 1, + id: 'com.atproto.server.getSession', + defs: { + main: { + type: 'query', + description: 'Get information about the current session.', + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['handle', 'did'], + properties: { + handle: { + type: 'string', + format: 'handle', + }, + did: { + type: 'string', + format: 'did', + }, + }, + }, + }, + }, + }, + }, ComAtprotoServerRefreshSession: { lexicon: 1, id: 'com.atproto.server.refreshSession', @@ -4256,6 +4404,8 @@ export const lexicons: Lexicons = new Lexicons(schemas) export const ids = { ComAtprotoAdminDefs: 'com.atproto.admin.defs', ComAtprotoAdminDisableInviteCodes: 'com.atproto.admin.disableInviteCodes', + ComAtprotoAdminGetInviteCodeUsage: 'com.atproto.admin.getInviteCodeUsage', + ComAtprotoAdminGetInviteCodes: 'com.atproto.admin.getInviteCodes', ComAtprotoAdminGetModerationAction: 'com.atproto.admin.getModerationAction', ComAtprotoAdminGetModerationActions: 'com.atproto.admin.getModerationActions', ComAtprotoAdminGetModerationReport: 'com.atproto.admin.getModerationReport', @@ -4287,9 +4437,9 @@ export const ids = { ComAtprotoServerDeleteAccount: 'com.atproto.server.deleteAccount', ComAtprotoServerDeleteSession: 'com.atproto.server.deleteSession', ComAtprotoServerDescribeServer: 'com.atproto.server.describeServer', - ComAtprotoServerGetSession: 'com.atproto.server.getSession', ComAtprotoServerGetAccountInviteCodes: 'com.atproto.server.getAccountInviteCodes', + ComAtprotoServerGetSession: 'com.atproto.server.getSession', ComAtprotoServerRefreshSession: 'com.atproto.server.refreshSession', ComAtprotoServerRequestAccountDelete: 'com.atproto.server.requestAccountDelete', diff --git a/packages/api/src/client/types/com/atproto/admin/getInviteCodeUsage.ts b/packages/api/src/client/types/com/atproto/admin/getInviteCodeUsage.ts new file mode 100644 index 00000000000..2487f7345ac --- /dev/null +++ b/packages/api/src/client/types/com/atproto/admin/getInviteCodeUsage.ts @@ -0,0 +1,58 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { Headers, XRPCError } from '@atproto/xrpc' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { isObj, hasProp } from '../../../../util' +import { lexicons } from '../../../../lexicons' +import { CID } from 'multiformats/cid' + +export interface QueryParams {} + +export type InputSchema = undefined + +export interface OutputSchema { + total?: CodesDetail + user?: CodesDetail + admin?: CodesDetail + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} + +export interface CodesDetail { + count: number + available: number + used: number + disabled: number + [k: string]: unknown +} + +export function isCodesDetail(v: unknown): v is CodesDetail { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.admin.getInviteCodeUsage#codesDetail' + ) +} + +export function validateCodesDetail(v: unknown): ValidationResult { + return lexicons.validate( + 'com.atproto.admin.getInviteCodeUsage#codesDetail', + v, + ) +} diff --git a/packages/api/src/client/types/com/atproto/admin/getInviteCodes.ts b/packages/api/src/client/types/com/atproto/admin/getInviteCodes.ts new file mode 100644 index 00000000000..2a94fe01195 --- /dev/null +++ b/packages/api/src/client/types/com/atproto/admin/getInviteCodes.ts @@ -0,0 +1,79 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { Headers, XRPCError } from '@atproto/xrpc' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { isObj, hasProp } from '../../../../util' +import { lexicons } from '../../../../lexicons' +import { CID } from 'multiformats/cid' + +export interface QueryParams { + sort?: 'recent' | 'usage' | (string & {}) + limit?: number + cursor?: string +} + +export type InputSchema = undefined + +export interface OutputSchema { + cursor: string + codes: CodeDetail[] + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} + +export interface CodeDetail { + code: string + available: number + disabled: boolean + forAccount: string + createdBy: string + createdAt: string + uses: CodeUse[] + [k: string]: unknown +} + +export function isCodeDetail(v: unknown): v is CodeDetail { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.admin.getInviteCodes#codeDetail' + ) +} + +export function validateCodeDetail(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.admin.getInviteCodes#codeDetail', v) +} + +export interface CodeUse { + usedBy: string + usedAt: string + [k: string]: unknown +} + +export function isCodeUse(v: unknown): v is CodeUse { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.admin.getInviteCodes#codeUse' + ) +} + +export function validateCodeUse(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.admin.getInviteCodes#codeUse', v) +} diff --git a/packages/pds/src/api/com/atproto/admin/disableInviteCodes.ts b/packages/pds/src/api/com/atproto/admin/disableInviteCodes.ts index 3da853ee96e..3faef45dc33 100644 --- a/packages/pds/src/api/com/atproto/admin/disableInviteCodes.ts +++ b/packages/pds/src/api/com/atproto/admin/disableInviteCodes.ts @@ -1,12 +1,6 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -type Total = { - count: number - open: number - used: number -} - export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.disableInviteCodes({ auth: ctx.adminVerifier, diff --git a/packages/pds/src/api/com/atproto/admin/getInviteCodeUsage.ts b/packages/pds/src/api/com/atproto/admin/getInviteCodeUsage.ts new file mode 100644 index 00000000000..fa9d0e599f2 --- /dev/null +++ b/packages/pds/src/api/com/atproto/admin/getInviteCodeUsage.ts @@ -0,0 +1,73 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { sql } from 'kysely' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.admin.getInviteCodeUsage({ + auth: ctx.adminVerifier, + handler: async () => { + const res = await ctx.db.db + .with('use_count', (qb) => + qb + .selectFrom('invite_code_use') + .groupBy('code') + .select(['code', sql`count(usedBy)`.as('uses')]), + ) + .selectFrom('invite_code') + .leftJoin('use_count', 'use_count.code', 'invite_code.code') + .groupBy('invite_code.code') + .select([ + 'invite_code.createdBy as createdBy', + 'invite_code.availableUses as available', + 'invite_code.disabled as disabled', + 'use_count.uses as uses', + ]) + .execute() + + const total = res.reduce(reducer, empty()) + const user = res + .filter((row) => row.createdBy !== 'admin') + .reduce(reducer, empty()) + const admin = res + .filter((row) => row.createdBy === 'admin') + .reduce(reducer, empty()) + + return { + encoding: 'application/json', + body: { + total, + user, + admin, + }, + } + }, + }) +} + +type CodesDetail = { + count: number + available: number + used: number + disabled: number +} + +type Row = { + available: number + disabled: 1 | 0 + uses: number | null +} + +const empty = () => ({ + count: 0, + available: 0, + used: 0, + disabled: 0, +}) + +const reducer = (acc: CodesDetail, cur: Row) => { + acc.count += 1 + acc.available += cur.available + acc.disabled += cur.disabled + acc.used += cur.uses ?? 0 + return acc +} diff --git a/packages/pds/src/api/com/atproto/admin/getInviteCodes.ts b/packages/pds/src/api/com/atproto/admin/getInviteCodes.ts new file mode 100644 index 00000000000..b69927fad46 --- /dev/null +++ b/packages/pds/src/api/com/atproto/admin/getInviteCodes.ts @@ -0,0 +1,137 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { sql } from 'kysely' +import { InvalidRequestError } from '@atproto/xrpc-server' +import { + LabeledResult, + Cursor, + GenericKeyset, + paginate, +} from '../../../../db/pagination' +import { + CodeDetail, + CodeUse, +} from '../../../../lexicon/types/com/atproto/admin/getInviteCodes' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.admin.getInviteCodes({ + auth: ctx.adminVerifier, + handler: async ({ params }) => { + const { sort, limit, cursor } = params + let builder = ctx.db.db + .with('use_count', (qb) => + qb + .selectFrom('invite_code_use') + .groupBy('code') + .select(['code', sql`count(usedBy)`.as('uses')]), + ) + .selectFrom('invite_code') + .leftJoin('use_count', 'invite_code.code', 'use_count.code') + .select([ + 'invite_code.code as code', + 'invite_code.availableUses as available', + 'invite_code.disabled as disabled', + 'invite_code.forUser as forAccount', + 'invite_code.createdBy as createdBy', + 'invite_code.createdAt as createdAt', + 'use_count.uses as uses', + ]) + + const ref = ctx.db.db.dynamic.ref + let keyset + if (sort === 'recent') { + keyset = new TimeCodeKeyset(ref('createdAt'), ref('code')) + } else if (sort === 'usage') { + keyset = new UseCodeKeyset(ref('uses'), ref('code')) + } else { + throw new InvalidRequestError(`unknown sort method: ${sort}`) + } + + builder = paginate(builder, { + limit, + cursor, + keyset, + }) + + const res = await builder.execute() + + const uses: Record = {} + const codes = res.map((row) => row.code) + if (codes.length > 0) { + const usesRes = await ctx.db.db + .selectFrom('invite_code_use') + .where('code', 'in', codes) + .selectAll() + .execute() + for (const use of usesRes) { + const { code, usedBy, usedAt } = use + uses[code] ??= [] + uses[code].push({ usedBy, usedAt }) + } + } + + const resultCursor = keyset.packFromResult(res) + const codeDetails: CodeDetail[] = res.map((row) => ({ + ...row, + disabled: row.disabled === 1, + uses: uses[row.code] ?? [], + })) + + return { + encoding: 'application/json', + body: { + cursor: resultCursor, + codes: codeDetails, + }, + } + }, + }) +} + +type TimeCodeResult = { createdAt: string; code: string } + +export class TimeCodeKeyset extends GenericKeyset { + labelResult(result: TimeCodeResult): Cursor { + return { primary: result.createdAt, secondary: result.code } + } + labeledResultToCursor(labeled: Cursor) { + return { + primary: new Date(labeled.primary).getTime().toString(), + secondary: labeled.secondary, + } + } + cursorToLabeledResult(cursor: Cursor) { + const primaryDate = new Date(parseInt(cursor.primary, 10)) + if (isNaN(primaryDate.getTime())) { + throw new InvalidRequestError('Malformed cursor') + } + return { + primary: primaryDate.toISOString(), + secondary: cursor.secondary, + } + } +} + +type UseCodeResult = { uses: number; code: string } + +export class UseCodeKeyset extends GenericKeyset { + labelResult(result: UseCodeResult): LabeledResult { + return { primary: result.uses, secondary: result.code } + } + labeledResultToCursor(labeled: Cursor) { + return { + primary: new Date(labeled.primary).getTime().toString(), + secondary: labeled.secondary, + } + } + cursorToLabeledResult(cursor: Cursor) { + const primaryDate = new Date(parseInt(cursor.primary, 10)) + if (isNaN(primaryDate.getTime())) { + throw new InvalidRequestError('Malformed cursor') + } + return { + primary: primaryDate.toISOString(), + secondary: cursor.secondary, + } + } +} diff --git a/packages/pds/src/api/com/atproto/admin/index.ts b/packages/pds/src/api/com/atproto/admin/index.ts index a75dc4d1a45..5dfe99e7066 100644 --- a/packages/pds/src/api/com/atproto/admin/index.ts +++ b/packages/pds/src/api/com/atproto/admin/index.ts @@ -11,6 +11,8 @@ import getModerationActions from './getModerationActions' import getModerationReport from './getModerationReport' import getModerationReports from './getModerationReports' import disableInviteCodes from './disableInviteCodes' +import getInviteCodeUsage from './getInviteCodeUsage' +import getInviteCodes from './getInviteCodes' export default function (server: Server, ctx: AppContext) { resolveModerationReports(server, ctx) @@ -24,4 +26,6 @@ export default function (server: Server, ctx: AppContext) { getModerationReport(server, ctx) getModerationReports(server, ctx) disableInviteCodes(server, ctx) + getInviteCodeUsage(server, ctx) + getInviteCodes(server, ctx) } diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index fca45989d3a..6035f9a83ca 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -10,6 +10,8 @@ import { } from '@atproto/xrpc-server' import { schemas } from './lexicons' import * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes' +import * as ComAtprotoAdminGetInviteCodeUsage from './types/com/atproto/admin/getInviteCodeUsage' +import * as ComAtprotoAdminGetInviteCodes from './types/com/atproto/admin/getInviteCodes' import * as ComAtprotoAdminGetModerationAction from './types/com/atproto/admin/getModerationAction' import * as ComAtprotoAdminGetModerationActions from './types/com/atproto/admin/getModerationActions' import * as ComAtprotoAdminGetModerationReport from './types/com/atproto/admin/getModerationReport' @@ -37,8 +39,8 @@ import * as ComAtprotoServerCreateSession from './types/com/atproto/server/creat import * as ComAtprotoServerDeleteAccount from './types/com/atproto/server/deleteAccount' import * as ComAtprotoServerDeleteSession from './types/com/atproto/server/deleteSession' import * as ComAtprotoServerDescribeServer from './types/com/atproto/server/describeServer' -import * as ComAtprotoServerGetSession from './types/com/atproto/server/getSession' import * as ComAtprotoServerGetAccountInviteCodes from './types/com/atproto/server/getAccountInviteCodes' +import * as ComAtprotoServerGetSession from './types/com/atproto/server/getSession' import * as ComAtprotoServerRefreshSession from './types/com/atproto/server/refreshSession' import * as ComAtprotoServerRequestAccountDelete from './types/com/atproto/server/requestAccountDelete' import * as ComAtprotoServerRequestPasswordReset from './types/com/atproto/server/requestPasswordReset' @@ -147,6 +149,23 @@ export class AdminNS { return this._server.xrpc.method(nsid, cfg) } + getInviteCodeUsage( + cfg: ConfigOf< + AV, + ComAtprotoAdminGetInviteCodeUsage.Handler> + >, + ) { + const nsid = 'com.atproto.admin.getInviteCodeUsage' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + + getInviteCodes( + cfg: ConfigOf>>, + ) { + const nsid = 'com.atproto.admin.getInviteCodes' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + getModerationAction( cfg: ConfigOf< AV, @@ -395,13 +414,6 @@ export class ServerNS { return this._server.xrpc.method(nsid, cfg) } - getSession( - cfg: ConfigOf>>, - ) { - const nsid = 'com.atproto.server.getSession' // @ts-ignore - return this._server.xrpc.method(nsid, cfg) - } - getAccountInviteCodes( cfg: ConfigOf< AV, @@ -412,6 +424,13 @@ export class ServerNS { return this._server.xrpc.method(nsid, cfg) } + getSession( + cfg: ConfigOf>>, + ) { + const nsid = 'com.atproto.server.getSession' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + refreshSession( cfg: ConfigOf>>, ) { diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 4a6a328d409..a6f068512de 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -561,6 +561,154 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminGetInviteCodeUsage: { + lexicon: 1, + id: 'com.atproto.admin.getInviteCodeUsage', + defs: { + main: { + type: 'query', + description: 'High level stats about invite code usage', + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['subject', 'follows'], + properties: { + total: { + type: 'ref', + ref: 'lex:com.atproto.admin.getInviteCodeUsage#codesDetail', + }, + user: { + type: 'ref', + ref: 'lex:com.atproto.admin.getInviteCodeUsage#codesDetail', + }, + admin: { + type: 'ref', + ref: 'lex:com.atproto.admin.getInviteCodeUsage#codesDetail', + }, + }, + }, + }, + }, + codesDetail: { + type: 'object', + required: ['count', 'available', 'used', 'disabled'], + properties: { + count: { + type: 'integer', + }, + available: { + type: 'integer', + }, + used: { + type: 'integer', + }, + disabled: { + type: 'integer', + }, + }, + }, + }, + }, + ComAtprotoAdminGetInviteCodes: { + lexicon: 1, + id: 'com.atproto.admin.getInviteCodes', + defs: { + main: { + type: 'query', + description: 'Admin view of invite codes', + parameters: { + type: 'params', + properties: { + sort: { + type: 'string', + knownValues: ['recent', 'usage'], + default: 'recent', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 1000, + default: 500, + }, + cursor: { + type: 'string', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['cursor', 'codes'], + properties: { + cursor: { + type: 'string', + }, + codes: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.admin.getInviteCodes#codeDetail', + }, + }, + }, + }, + }, + }, + codeDetail: { + type: 'object', + required: [ + 'code', + 'available', + 'disabled', + 'forAccount', + 'createdBy', + 'createdAt', + 'uses', + ], + properties: { + code: { + type: 'string', + }, + available: { + type: 'integer', + }, + disabled: { + type: 'boolean', + }, + forAccount: { + type: 'string', + }, + createdBy: { + type: 'string', + }, + createdAt: { + type: 'string', + }, + uses: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.admin.getInviteCodes#codeUse', + }, + }, + }, + }, + codeUse: { + type: 'object', + required: ['usedBy', 'usedAt'], + properties: { + usedBy: { + type: 'string', + }, + usedAt: { + type: 'string', + }, + }, + }, + }, + }, ComAtprotoAdminGetModerationAction: { lexicon: 1, id: 'com.atproto.admin.getModerationAction', @@ -1897,33 +2045,6 @@ export const schemaDict = { }, }, }, - ComAtprotoServerGetSession: { - lexicon: 1, - id: 'com.atproto.server.getSession', - defs: { - main: { - type: 'query', - description: 'Get information about the current session.', - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['handle', 'did'], - properties: { - handle: { - type: 'string', - format: 'handle', - }, - did: { - type: 'string', - format: 'did', - }, - }, - }, - }, - }, - }, - }, ComAtprotoServerGetAccountInviteCodes: { lexicon: 1, id: 'com.atproto.server.getAccountInviteCodes', @@ -1986,6 +2107,33 @@ export const schemaDict = { }, }, }, + ComAtprotoServerGetSession: { + lexicon: 1, + id: 'com.atproto.server.getSession', + defs: { + main: { + type: 'query', + description: 'Get information about the current session.', + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['handle', 'did'], + properties: { + handle: { + type: 'string', + format: 'handle', + }, + did: { + type: 'string', + format: 'did', + }, + }, + }, + }, + }, + }, + }, ComAtprotoServerRefreshSession: { lexicon: 1, id: 'com.atproto.server.refreshSession', @@ -4256,6 +4404,8 @@ export const lexicons: Lexicons = new Lexicons(schemas) export const ids = { ComAtprotoAdminDefs: 'com.atproto.admin.defs', ComAtprotoAdminDisableInviteCodes: 'com.atproto.admin.disableInviteCodes', + ComAtprotoAdminGetInviteCodeUsage: 'com.atproto.admin.getInviteCodeUsage', + ComAtprotoAdminGetInviteCodes: 'com.atproto.admin.getInviteCodes', ComAtprotoAdminGetModerationAction: 'com.atproto.admin.getModerationAction', ComAtprotoAdminGetModerationActions: 'com.atproto.admin.getModerationActions', ComAtprotoAdminGetModerationReport: 'com.atproto.admin.getModerationReport', @@ -4287,9 +4437,9 @@ export const ids = { ComAtprotoServerDeleteAccount: 'com.atproto.server.deleteAccount', ComAtprotoServerDeleteSession: 'com.atproto.server.deleteSession', ComAtprotoServerDescribeServer: 'com.atproto.server.describeServer', - ComAtprotoServerGetSession: 'com.atproto.server.getSession', ComAtprotoServerGetAccountInviteCodes: 'com.atproto.server.getAccountInviteCodes', + ComAtprotoServerGetSession: 'com.atproto.server.getSession', ComAtprotoServerRefreshSession: 'com.atproto.server.refreshSession', ComAtprotoServerRequestAccountDelete: 'com.atproto.server.requestAccountDelete', diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/getInviteCodeUsage.ts b/packages/pds/src/lexicon/types/com/atproto/admin/getInviteCodeUsage.ts new file mode 100644 index 00000000000..6f4fe71db87 --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/admin/getInviteCodeUsage.ts @@ -0,0 +1,64 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export type InputSchema = undefined + +export interface OutputSchema { + total?: CodesDetail + user?: CodesDetail + admin?: CodesDetail + [k: string]: unknown +} + +export type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type Handler = (ctx: { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +}) => Promise | HandlerOutput + +export interface CodesDetail { + count: number + available: number + used: number + disabled: number + [k: string]: unknown +} + +export function isCodesDetail(v: unknown): v is CodesDetail { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.admin.getInviteCodeUsage#codesDetail' + ) +} + +export function validateCodesDetail(v: unknown): ValidationResult { + return lexicons.validate( + 'com.atproto.admin.getInviteCodeUsage#codesDetail', + v, + ) +} diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/getInviteCodes.ts b/packages/pds/src/lexicon/types/com/atproto/admin/getInviteCodes.ts new file mode 100644 index 00000000000..dcd22b7379b --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/admin/getInviteCodes.ts @@ -0,0 +1,85 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams { + sort: 'recent' | 'usage' | (string & {}) + limit: number + cursor?: string +} + +export type InputSchema = undefined + +export interface OutputSchema { + cursor: string + codes: CodeDetail[] + [k: string]: unknown +} + +export type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type Handler = (ctx: { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +}) => Promise | HandlerOutput + +export interface CodeDetail { + code: string + available: number + disabled: boolean + forAccount: string + createdBy: string + createdAt: string + uses: CodeUse[] + [k: string]: unknown +} + +export function isCodeDetail(v: unknown): v is CodeDetail { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.admin.getInviteCodes#codeDetail' + ) +} + +export function validateCodeDetail(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.admin.getInviteCodes#codeDetail', v) +} + +export interface CodeUse { + usedBy: string + usedAt: string + [k: string]: unknown +} + +export function isCodeUse(v: unknown): v is CodeUse { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.admin.getInviteCodes#codeUse' + ) +} + +export function validateCodeUse(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.admin.getInviteCodes#codeUse', v) +} From 7539b5d22d7de4efd3a50ac6f9c56332d41cdbd1 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 4 Apr 2023 20:09:20 -0500 Subject: [PATCH 11/15] tests for invite admin views --- .../com/atproto/admin/getInviteCodeUsage.json | 2 +- .../com/atproto/admin/getInviteCodes.json | 2 +- .../com/atproto/server/createInviteCode.json | 2 +- packages/api/src/client/lexicons.ts | 10 +- .../com/atproto/admin/getInviteCodeUsage.ts | 6 +- .../types/com/atproto/admin/getInviteCodes.ts | 2 +- .../com/atproto/server/createInviteCode.ts | 2 +- .../com/atproto/admin/disableInviteCodes.ts | 4 + .../com/atproto/admin/getInviteCodeUsage.ts | 9 +- .../api/com/atproto/admin/getInviteCodes.ts | 18 +- .../com/atproto/server/createInviteCode.ts | 4 +- .../atproto/server/getAccountInviteCodes.ts | 7 +- packages/pds/src/db/util.ts | 9 + packages/pds/src/lexicon/lexicons.ts | 10 +- .../com/atproto/admin/getInviteCodeUsage.ts | 6 +- .../types/com/atproto/admin/getInviteCodes.ts | 2 +- .../com/atproto/server/createInviteCode.ts | 2 +- packages/pds/tests/_util.ts | 7 + packages/pds/tests/account.test.ts | 51 ++++- .../pds/tests/views/admin/invites.test.ts | 183 ++++++++++++++++++ 20 files changed, 297 insertions(+), 41 deletions(-) create mode 100644 packages/pds/tests/views/admin/invites.test.ts diff --git a/lexicons/com/atproto/admin/getInviteCodeUsage.json b/lexicons/com/atproto/admin/getInviteCodeUsage.json index 033855898fd..25135b297e5 100644 --- a/lexicons/com/atproto/admin/getInviteCodeUsage.json +++ b/lexicons/com/atproto/admin/getInviteCodeUsage.json @@ -9,7 +9,7 @@ "encoding": "application/json", "schema": { "type": "object", - "required": ["subject", "follows"], + "required": ["total", "user", "admin"], "properties": { "total": { "type": "ref", "ref": "#codesDetail" }, "user": { "type": "ref", "ref": "#codesDetail" }, diff --git a/lexicons/com/atproto/admin/getInviteCodes.json b/lexicons/com/atproto/admin/getInviteCodes.json index 0b710e11b15..667c134a66c 100644 --- a/lexicons/com/atproto/admin/getInviteCodes.json +++ b/lexicons/com/atproto/admin/getInviteCodes.json @@ -24,7 +24,7 @@ "encoding": "application/json", "schema": { "type": "object", - "required": ["cursor", "codes"], + "required": ["codes"], "properties": { "cursor": {"type": "string"}, "codes": { diff --git a/lexicons/com/atproto/server/createInviteCode.json b/lexicons/com/atproto/server/createInviteCode.json index 2d88d8f2d4d..81a967d8a30 100644 --- a/lexicons/com/atproto/server/createInviteCode.json +++ b/lexicons/com/atproto/server/createInviteCode.json @@ -12,7 +12,7 @@ "required": ["useCount"], "properties": { "useCount": {"type": "integer"}, - "forUser": {"type": "string", "format": "did"} + "forAccount": {"type": "string", "format": "did"} } } }, diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index a6f068512de..156950eb5f3 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -572,7 +572,7 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['subject', 'follows'], + required: ['total', 'user', 'admin'], properties: { total: { type: 'ref', @@ -628,8 +628,8 @@ export const schemaDict = { limit: { type: 'integer', minimum: 1, - maximum: 1000, - default: 500, + maximum: 500, + default: 100, }, cursor: { type: 'string', @@ -640,7 +640,7 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['cursor', 'codes'], + required: ['codes'], properties: { cursor: { type: 'string', @@ -1877,7 +1877,7 @@ export const schemaDict = { useCount: { type: 'integer', }, - forUser: { + forAccount: { type: 'string', format: 'did', }, diff --git a/packages/api/src/client/types/com/atproto/admin/getInviteCodeUsage.ts b/packages/api/src/client/types/com/atproto/admin/getInviteCodeUsage.ts index 2487f7345ac..1463f2d00f3 100644 --- a/packages/api/src/client/types/com/atproto/admin/getInviteCodeUsage.ts +++ b/packages/api/src/client/types/com/atproto/admin/getInviteCodeUsage.ts @@ -12,9 +12,9 @@ export interface QueryParams {} export type InputSchema = undefined export interface OutputSchema { - total?: CodesDetail - user?: CodesDetail - admin?: CodesDetail + total: CodesDetail + user: CodesDetail + admin: CodesDetail [k: string]: unknown } diff --git a/packages/api/src/client/types/com/atproto/admin/getInviteCodes.ts b/packages/api/src/client/types/com/atproto/admin/getInviteCodes.ts index 2a94fe01195..e2d55bb45ef 100644 --- a/packages/api/src/client/types/com/atproto/admin/getInviteCodes.ts +++ b/packages/api/src/client/types/com/atproto/admin/getInviteCodes.ts @@ -16,7 +16,7 @@ export interface QueryParams { export type InputSchema = undefined export interface OutputSchema { - cursor: string + cursor?: string codes: CodeDetail[] [k: string]: unknown } diff --git a/packages/api/src/client/types/com/atproto/server/createInviteCode.ts b/packages/api/src/client/types/com/atproto/server/createInviteCode.ts index 8f48d22c1ec..a1ce922dd79 100644 --- a/packages/api/src/client/types/com/atproto/server/createInviteCode.ts +++ b/packages/api/src/client/types/com/atproto/server/createInviteCode.ts @@ -11,7 +11,7 @@ export interface QueryParams {} export interface InputSchema { useCount: number - forUser?: string + forAccount?: string [k: string]: unknown } diff --git a/packages/pds/src/api/com/atproto/admin/disableInviteCodes.ts b/packages/pds/src/api/com/atproto/admin/disableInviteCodes.ts index 3faef45dc33..fb7e387bbaf 100644 --- a/packages/pds/src/api/com/atproto/admin/disableInviteCodes.ts +++ b/packages/pds/src/api/com/atproto/admin/disableInviteCodes.ts @@ -1,11 +1,15 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { InvalidRequestError } from '@atproto/xrpc-server' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.disableInviteCodes({ auth: ctx.adminVerifier, handler: async ({ input }) => { const { codes = [], accounts = [] } = input.body + if (accounts.includes('admin')) { + throw new InvalidRequestError('cannot disable admin invite codes') + } if (codes.length > 0) { await ctx.db.db .updateTable('invite_code') diff --git a/packages/pds/src/api/com/atproto/admin/getInviteCodeUsage.ts b/packages/pds/src/api/com/atproto/admin/getInviteCodeUsage.ts index fa9d0e599f2..cd9677c7151 100644 --- a/packages/pds/src/api/com/atproto/admin/getInviteCodeUsage.ts +++ b/packages/pds/src/api/com/atproto/admin/getInviteCodeUsage.ts @@ -1,26 +1,27 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { sql } from 'kysely' +import { nullToZero } from '../../../../db/util' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getInviteCodeUsage({ auth: ctx.adminVerifier, handler: async () => { + const ref = ctx.db.db.dynamic.ref const res = await ctx.db.db .with('use_count', (qb) => qb .selectFrom('invite_code_use') .groupBy('code') - .select(['code', sql`count(usedBy)`.as('uses')]), + .select(['code', sql`count(*)`.as('uses')]), ) .selectFrom('invite_code') .leftJoin('use_count', 'use_count.code', 'invite_code.code') - .groupBy('invite_code.code') .select([ 'invite_code.createdBy as createdBy', 'invite_code.availableUses as available', 'invite_code.disabled as disabled', - 'use_count.uses as uses', + nullToZero(ctx.db, ref('use_count.uses')).as('uses'), ]) .execute() @@ -54,7 +55,7 @@ type CodesDetail = { type Row = { available: number disabled: 1 | 0 - uses: number | null + uses: number } const empty = () => ({ diff --git a/packages/pds/src/api/com/atproto/admin/getInviteCodes.ts b/packages/pds/src/api/com/atproto/admin/getInviteCodes.ts index b69927fad46..3a07164ec00 100644 --- a/packages/pds/src/api/com/atproto/admin/getInviteCodes.ts +++ b/packages/pds/src/api/com/atproto/admin/getInviteCodes.ts @@ -12,18 +12,20 @@ import { CodeDetail, CodeUse, } from '../../../../lexicon/types/com/atproto/admin/getInviteCodes' +import { nullToZero } from '../../../../db/util' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getInviteCodes({ auth: ctx.adminVerifier, handler: async ({ params }) => { const { sort, limit, cursor } = params - let builder = ctx.db.db + const ref = ctx.db.db.dynamic.ref + const innerBuilder = ctx.db.db .with('use_count', (qb) => qb .selectFrom('invite_code_use') .groupBy('code') - .select(['code', sql`count(usedBy)`.as('uses')]), + .select(['code', sql`count(*)`.as('uses')]), ) .selectFrom('invite_code') .leftJoin('use_count', 'invite_code.code', 'use_count.code') @@ -34,10 +36,9 @@ export default function (server: Server, ctx: AppContext) { 'invite_code.forUser as forAccount', 'invite_code.createdBy as createdBy', 'invite_code.createdAt as createdAt', - 'use_count.uses as uses', + nullToZero(ctx.db, ref('use_count.uses')).as('uses'), ]) - const ref = ctx.db.db.dynamic.ref let keyset if (sort === 'recent') { keyset = new TimeCodeKeyset(ref('createdAt'), ref('code')) @@ -47,6 +48,7 @@ export default function (server: Server, ctx: AppContext) { throw new InvalidRequestError(`unknown sort method: ${sort}`) } + let builder = ctx.db.db.selectFrom(innerBuilder.as('codes')).selectAll() builder = paginate(builder, { limit, cursor, @@ -120,17 +122,17 @@ export class UseCodeKeyset extends GenericKeyset { } labeledResultToCursor(labeled: Cursor) { return { - primary: new Date(labeled.primary).getTime().toString(), + primary: labeled.primary.toString(), secondary: labeled.secondary, } } cursorToLabeledResult(cursor: Cursor) { - const primaryDate = new Date(parseInt(cursor.primary, 10)) - if (isNaN(primaryDate.getTime())) { + const primaryCode = parseInt(cursor.primary, 10) + if (isNaN(primaryCode)) { throw new InvalidRequestError('Malformed cursor') } return { - primary: primaryDate.toISOString(), + primary: primaryCode, secondary: cursor.secondary, } } diff --git a/packages/pds/src/api/com/atproto/server/createInviteCode.ts b/packages/pds/src/api/com/atproto/server/createInviteCode.ts index 5867c6d5b91..cde04c12aa1 100644 --- a/packages/pds/src/api/com/atproto/server/createInviteCode.ts +++ b/packages/pds/src/api/com/atproto/server/createInviteCode.ts @@ -6,7 +6,7 @@ export default function (server: Server, ctx: AppContext) { server.com.atproto.server.createInviteCode({ auth: ctx.adminVerifier, handler: async ({ input, req }) => { - const { useCount, forUser = 'admin' } = input.body + const { useCount, forAccount = 'admin' } = input.body const code = genInvCode(ctx.cfg) @@ -16,7 +16,7 @@ export default function (server: Server, ctx: AppContext) { code: code, availableUses: useCount, disabled: 0, - forUser, + forUser: forAccount, createdBy: 'admin', createdAt: new Date().toISOString(), }) diff --git a/packages/pds/src/api/com/atproto/server/getAccountInviteCodes.ts b/packages/pds/src/api/com/atproto/server/getAccountInviteCodes.ts index 718b1a9ea49..e8a6d48af3f 100644 --- a/packages/pds/src/api/com/atproto/server/getAccountInviteCodes.ts +++ b/packages/pds/src/api/com/atproto/server/getAccountInviteCodes.ts @@ -3,6 +3,7 @@ import AppContext from '../../../../context' import { genInvCodes } from './util' import { sql } from 'kysely' import { InvalidRequestError } from '@atproto/xrpc-server' +import { nullToZero } from '../../../../db/util' export default function (server: Server, ctx: AppContext) { server.com.atproto.server.getAccountInviteCodes({ @@ -11,6 +12,7 @@ export default function (server: Server, ctx: AppContext) { const requester = auth.credentials.did const { includeUsed, createAvailable } = params + const ref = ctx.db.db.dynamic.ref const [user, userCodesRes] = await Promise.all([ ctx.db.db .selectFrom('user_account') @@ -22,7 +24,7 @@ export default function (server: Server, ctx: AppContext) { qb .selectFrom('invite_code_use') .groupBy('code') - .select(['code', sql`count(usedBy)`.as('uses')]), + .select(['code', sql`count(*)`.as('uses')]), ) .selectFrom('invite_code') .leftJoin('use_count', 'use_count.code', 'invite_code.code') @@ -32,13 +34,12 @@ export default function (server: Server, ctx: AppContext) { 'invite_code.code as code', 'invite_code.availableUses as available', 'invite_code.disabled as disabled', - 'use_count.uses as uses', + nullToZero(ctx.db, ref('use_count.uses')).as('uses'), ]) .execute(), ]) const userCodes = userCodesRes.map((row) => ({ ...row, - uses: row.uses ?? 0, disabled: row.disabled === 1, })) const unusedCodes = userCodes.filter((row) => row.available > row.uses) diff --git a/packages/pds/src/db/util.ts b/packages/pds/src/db/util.ts index f1c8b40fc9a..ad820cab062 100644 --- a/packages/pds/src/db/util.ts +++ b/packages/pds/src/db/util.ts @@ -7,6 +7,7 @@ import { SqliteIntrospector, SqliteQueryCompiler, } from 'kysely' +import Database from '.' export const actorWhereClause = (actor: string) => { if (actor.startsWith('did:')) { @@ -27,6 +28,14 @@ export const softDeleted = (repoOrRecord: { takedownId: number | null }) => { export const countAll = sql`count(*)` +export const nullToZero = (db: Database, ref: DbRef) => { + if (db.dialect === 'pg') { + return sql`coalesce(${ref}, 0)` + } else { + return sql`ifnull(${ref}, 0)` + } +} + export const dummyDialect = { createAdapter() { return new SqliteAdapter() diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index a6f068512de..156950eb5f3 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -572,7 +572,7 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['subject', 'follows'], + required: ['total', 'user', 'admin'], properties: { total: { type: 'ref', @@ -628,8 +628,8 @@ export const schemaDict = { limit: { type: 'integer', minimum: 1, - maximum: 1000, - default: 500, + maximum: 500, + default: 100, }, cursor: { type: 'string', @@ -640,7 +640,7 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['cursor', 'codes'], + required: ['codes'], properties: { cursor: { type: 'string', @@ -1877,7 +1877,7 @@ export const schemaDict = { useCount: { type: 'integer', }, - forUser: { + forAccount: { type: 'string', format: 'did', }, diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/getInviteCodeUsage.ts b/packages/pds/src/lexicon/types/com/atproto/admin/getInviteCodeUsage.ts index 6f4fe71db87..c8d80c57fee 100644 --- a/packages/pds/src/lexicon/types/com/atproto/admin/getInviteCodeUsage.ts +++ b/packages/pds/src/lexicon/types/com/atproto/admin/getInviteCodeUsage.ts @@ -13,9 +13,9 @@ export interface QueryParams {} export type InputSchema = undefined export interface OutputSchema { - total?: CodesDetail - user?: CodesDetail - admin?: CodesDetail + total: CodesDetail + user: CodesDetail + admin: CodesDetail [k: string]: unknown } diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/getInviteCodes.ts b/packages/pds/src/lexicon/types/com/atproto/admin/getInviteCodes.ts index dcd22b7379b..0723bf96f4c 100644 --- a/packages/pds/src/lexicon/types/com/atproto/admin/getInviteCodes.ts +++ b/packages/pds/src/lexicon/types/com/atproto/admin/getInviteCodes.ts @@ -17,7 +17,7 @@ export interface QueryParams { export type InputSchema = undefined export interface OutputSchema { - cursor: string + cursor?: string codes: CodeDetail[] [k: string]: unknown } diff --git a/packages/pds/src/lexicon/types/com/atproto/server/createInviteCode.ts b/packages/pds/src/lexicon/types/com/atproto/server/createInviteCode.ts index f7aeca8d1af..f95b0981678 100644 --- a/packages/pds/src/lexicon/types/com/atproto/server/createInviteCode.ts +++ b/packages/pds/src/lexicon/types/com/atproto/server/createInviteCode.ts @@ -12,7 +12,7 @@ export interface QueryParams {} export interface InputSchema { useCount: number - forUser?: string + forAccount?: string [k: string]: unknown } diff --git a/packages/pds/tests/_util.ts b/packages/pds/tests/_util.ts index 1f300086a6a..64467019223 100644 --- a/packages/pds/tests/_util.ts +++ b/packages/pds/tests/_util.ts @@ -78,6 +78,7 @@ export const runTestServer = async ( recoveryKey, adminPassword: ADMIN_PASSWORD, inviteRequired: false, + userInviteInterval: null, didPlcUrl: plcUrl, jwtSecret: 'jwt-secret', availableUserDomains: ['.test'], @@ -181,6 +182,12 @@ export const forSnapshot = (obj: unknown) => { if (str.match(/^\d+::bafy/)) { return constantKeysetCursor } + if (str.startsWith('pds-public-url-')) { + return 'invite-code' + } + if (str.match(/^\d+::pds-public-url-/)) { + return '0000000000000::invite-code' + } let isCid: boolean try { CID.parse(str) diff --git a/packages/pds/tests/account.test.ts b/packages/pds/tests/account.test.ts index f8ccc2db404..31d8a2aef2b 100644 --- a/packages/pds/tests/account.test.ts +++ b/packages/pds/tests/account.test.ts @@ -20,9 +20,10 @@ const minsToMs = 60 * 1000 const createInviteCode = async ( agent: AtpAgent, uses: number, + forUser?: string, ): Promise => { const res = await agent.api.com.atproto.server.createInviteCode( - { useCount: uses }, + { useCount: uses, forUser }, { headers: { authorization: util.adminAuth() }, encoding: 'application/json', @@ -502,4 +503,52 @@ describe('account', () => { }) expect(res4.data.codes.length).toBe(5) }) + + it('prevents use of disabled codes', async () => { + const first = await createInviteCode(agent, 1) + const accntCodes = + await agent.api.com.atproto.server.getAccountInviteCodes() + const second = accntCodes.data.codes[0].code + + // disabled first by code & second by did + await agent.api.com.atproto.admin.disableInviteCodes( + { + codes: [first], + accounts: [did], + }, + { + headers: { authorization: util.adminAuth() }, + encoding: 'application/json', + }, + ) + + const attempt = async (code: string) => { + await agent.api.com.atproto.server.createAccount({ + email: 'disable@test.com', + handle: 'disable.test', + inviteCode: code, + password: 'disabled', + }) + } + + await expect(attempt(first)).rejects.toThrow( + ComAtprotoServerCreateAccount.InvalidInviteCodeError, + ) + await expect(attempt(second)).rejects.toThrow( + ComAtprotoServerCreateAccount.InvalidInviteCodeError, + ) + }) + + it('does not allow disabling all admin codes', async () => { + const attempt = agent.api.com.atproto.admin.disableInviteCodes( + { + accounts: ['admin'], + }, + { + headers: { authorization: util.adminAuth() }, + encoding: 'application/json', + }, + ) + await expect(attempt).rejects.toThrow('cannot disable admin invite codes') + }) }) diff --git a/packages/pds/tests/views/admin/invites.test.ts b/packages/pds/tests/views/admin/invites.test.ts new file mode 100644 index 00000000000..18bb1272d23 --- /dev/null +++ b/packages/pds/tests/views/admin/invites.test.ts @@ -0,0 +1,183 @@ +import AtpAgent from '@atproto/api' +import { runTestServer, forSnapshot, CloseFn, adminAuth } from '../../_util' +import { SeedClient } from '../../seeds/client' +import basicSeed from '../../seeds/basic' +import { randomStr } from '@atproto/crypto' +import { wait } from '@atproto/common' + +describe('pds admin invite views', () => { + let agent: AtpAgent + let close: CloseFn + + beforeAll(async () => { + const server = await runTestServer({ + dbPostgresSchema: 'views_admin_invites', + inviteRequired: true, + userInviteInterval: 1, + }) + close = server.close + agent = new AtpAgent({ service: server.url }) + }) + + afterAll(async () => { + await close() + }) + + let alice: string + + beforeAll(async () => { + const adminCode = await agent.api.com.atproto.server.createInviteCode( + { useCount: 10 }, + { encoding: 'application/json', headers: { authorization: adminAuth() } }, + ) + + const aliceRes = await agent.api.com.atproto.server.createAccount({ + handle: 'alice.test', + email: 'alice@test.com', + password: 'alice', + inviteCode: adminCode.data.code, + }) + alice = aliceRes.data.did + const bobRes = await agent.api.com.atproto.server.createAccount({ + handle: 'bob.test', + email: 'bob@test.com', + password: 'bob', + inviteCode: adminCode.data.code, + }) + + const aliceCodes = await agent.api.com.atproto.server.getAccountInviteCodes( + {}, + { headers: { authorization: `Bearer ${aliceRes.data.accessJwt}` } }, + ) + await agent.api.com.atproto.server.getAccountInviteCodes( + {}, + { headers: { authorization: `Bearer ${bobRes.data.accessJwt}` } }, + ) + await agent.api.com.atproto.server.createInviteCode( + { useCount: 5, forAccount: aliceRes.data.did }, + { encoding: 'application/json', headers: { authorization: adminAuth() } }, + ) + await agent.api.com.atproto.admin.disableInviteCodes( + { codes: [adminCode.data.code], accounts: [bobRes.data.did] }, + { encoding: 'application/json', headers: { authorization: adminAuth() } }, + ) + + const useCode = async (code: string) => { + const name = randomStr(8, 'base32') + await agent.api.com.atproto.server.createAccount({ + handle: `${name}.test`, + email: `${name}@test.com`, + password: name, + inviteCode: code, + }) + } + + await useCode(aliceCodes.data.codes[0].code) + await useCode(aliceCodes.data.codes[1].code) + }) + + it('gets a list of invite codes by recency', async () => { + const result = await agent.api.com.atproto.admin.getInviteCodes( + {}, + { headers: { authorization: adminAuth() } }, + ) + let lastDate = result.data.codes[0].createdAt + for (const code of result.data.codes) { + expect(code.createdAt <= lastDate).toBeTruthy() + lastDate = code.createdAt + } + expect(result.data.codes.length).toBe(12) + expect(result.data.codes[0]).toMatchObject({ + available: 5, + disabled: false, + forAccount: alice, + createdBy: 'admin', + }) + expect(result.data.codes[0].uses.length).toBe(0) + expect(result.data.codes.at(-1)).toMatchObject({ + available: 10, + disabled: true, + forAccount: 'admin', + createdBy: 'admin', + }) + expect(result.data.codes.at(-1)?.uses.length).toBe(2) + }) + + it('paginates by recency', async () => { + const full = await agent.api.com.atproto.admin.getInviteCodes( + {}, + { headers: { authorization: adminAuth() } }, + ) + const first = await agent.api.com.atproto.admin.getInviteCodes( + { limit: 5 }, + { headers: { authorization: adminAuth() } }, + ) + const second = await agent.api.com.atproto.admin.getInviteCodes( + { cursor: first.data.cursor }, + { headers: { authorization: adminAuth() } }, + ) + const combined = [...first.data.codes, ...second.data.codes] + expect(combined).toEqual(full.data.codes) + }) + + it('gets a list of invite codes by usage', async () => { + const result = await agent.api.com.atproto.admin.getInviteCodes( + { sort: 'usage' }, + { headers: { authorization: adminAuth() } }, + ) + let lastUseCount = result.data.codes[0].uses.length + for (const code of result.data.codes) { + expect(code.uses.length).toBeLessThanOrEqual(lastUseCount) + lastUseCount = code.uses.length + } + expect(result.data.codes[0]).toMatchObject({ + available: 10, + disabled: true, + forAccount: 'admin', + createdBy: 'admin', + }) + expect(result.data.codes[0].uses.length).toBe(2) + }) + + it('paginates by usage', async () => { + const full = await agent.api.com.atproto.admin.getInviteCodes( + { sort: 'usage' }, + { headers: { authorization: adminAuth() } }, + ) + const first = await agent.api.com.atproto.admin.getInviteCodes( + { sort: 'usage', limit: 5 }, + { headers: { authorization: adminAuth() } }, + ) + const second = await agent.api.com.atproto.admin.getInviteCodes( + { sort: 'usage', cursor: first.data.cursor }, + { headers: { authorization: adminAuth() } }, + ) + const combined = [...first.data.codes, ...second.data.codes] + expect(combined).toEqual(full.data.codes) + }) + + it('gets high level invite code info', async () => { + const res = await agent.api.com.atproto.admin.getInviteCodeUsage( + {}, + { headers: { authorization: adminAuth() } }, + ) + expect(res.data.admin).toEqual({ + count: 2, + available: 15, + used: 2, + disabled: 1, + }) + expect(res.data.user).toEqual({ + count: 10, + available: 10, + used: 2, + disabled: 5, + }) + expect(res.data.total).toEqual({ + count: 12, + available: 25, + used: 4, + disabled: 6, + }) + }) +}) From 55978c71b8f42e87f0bf8e9da72423ed19c9ffa2 Mon Sep 17 00:00:00 2001 From: dholms Date: Wed, 5 Apr 2023 10:29:10 -0500 Subject: [PATCH 12/15] pr feedback --- lexicons/com/atproto/admin/getInviteCodes.json | 8 ++++---- .../pds/src/api/com/atproto/admin/getInviteCodeUsage.ts | 9 ++++----- packages/pds/src/api/com/atproto/admin/getInviteCodes.ts | 7 +++---- .../src/api/com/atproto/server/getAccountInviteCodes.ts | 7 +++---- packages/pds/src/db/util.ts | 9 ++------- 5 files changed, 16 insertions(+), 24 deletions(-) diff --git a/lexicons/com/atproto/admin/getInviteCodes.json b/lexicons/com/atproto/admin/getInviteCodes.json index 667c134a66c..f5a510fd8d2 100644 --- a/lexicons/com/atproto/admin/getInviteCodes.json +++ b/lexicons/com/atproto/admin/getInviteCodes.json @@ -43,8 +43,8 @@ "available": {"type": "integer"}, "disabled": {"type": "boolean"}, "forAccount": {"type": "string"}, - "createdBy": {"type": "string"}, - "createdAt": {"type": "string"}, + "createdBy": {"type": "string", "format": "datetime"}, + "createdAt": {"type": "string", "format": "datetime"}, "uses": { "type": "array", "items": {"type": "ref", "ref": "#codeUse"} @@ -55,8 +55,8 @@ "type": "object", "required": ["usedBy", "usedAt"], "properties": { - "usedBy": {"type": "string"}, - "usedAt": {"type": "string"} + "usedBy": {"type": "string", "format": "did"}, + "usedAt": {"type": "string", "format": "datetime"} } } } diff --git a/packages/pds/src/api/com/atproto/admin/getInviteCodeUsage.ts b/packages/pds/src/api/com/atproto/admin/getInviteCodeUsage.ts index cd9677c7151..93a91276272 100644 --- a/packages/pds/src/api/com/atproto/admin/getInviteCodeUsage.ts +++ b/packages/pds/src/api/com/atproto/admin/getInviteCodeUsage.ts @@ -1,7 +1,6 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { sql } from 'kysely' -import { nullToZero } from '../../../../db/util' +import { countAll, nullToZero } from '../../../../db/util' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getInviteCodeUsage({ @@ -13,7 +12,7 @@ export default function (server: Server, ctx: AppContext) { qb .selectFrom('invite_code_use') .groupBy('code') - .select(['code', sql`count(*)`.as('uses')]), + .select(['code', countAll.as('uses')]), ) .selectFrom('invite_code') .leftJoin('use_count', 'use_count.code', 'invite_code.code') @@ -21,7 +20,7 @@ export default function (server: Server, ctx: AppContext) { 'invite_code.createdBy as createdBy', 'invite_code.availableUses as available', 'invite_code.disabled as disabled', - nullToZero(ctx.db, ref('use_count.uses')).as('uses'), + nullToZero(ref('use_count.uses')).as('uses'), ]) .execute() @@ -69,6 +68,6 @@ const reducer = (acc: CodesDetail, cur: Row) => { acc.count += 1 acc.available += cur.available acc.disabled += cur.disabled - acc.used += cur.uses ?? 0 + acc.used += cur.uses return acc } diff --git a/packages/pds/src/api/com/atproto/admin/getInviteCodes.ts b/packages/pds/src/api/com/atproto/admin/getInviteCodes.ts index 3a07164ec00..ca640a8e6a8 100644 --- a/packages/pds/src/api/com/atproto/admin/getInviteCodes.ts +++ b/packages/pds/src/api/com/atproto/admin/getInviteCodes.ts @@ -1,6 +1,5 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { sql } from 'kysely' import { InvalidRequestError } from '@atproto/xrpc-server' import { LabeledResult, @@ -12,7 +11,7 @@ import { CodeDetail, CodeUse, } from '../../../../lexicon/types/com/atproto/admin/getInviteCodes' -import { nullToZero } from '../../../../db/util' +import { countAll, nullToZero } from '../../../../db/util' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getInviteCodes({ @@ -25,7 +24,7 @@ export default function (server: Server, ctx: AppContext) { qb .selectFrom('invite_code_use') .groupBy('code') - .select(['code', sql`count(*)`.as('uses')]), + .select(['code', countAll.as('uses')]), ) .selectFrom('invite_code') .leftJoin('use_count', 'invite_code.code', 'use_count.code') @@ -36,7 +35,7 @@ export default function (server: Server, ctx: AppContext) { 'invite_code.forUser as forAccount', 'invite_code.createdBy as createdBy', 'invite_code.createdAt as createdAt', - nullToZero(ctx.db, ref('use_count.uses')).as('uses'), + nullToZero(ref('use_count.uses')).as('uses'), ]) let keyset diff --git a/packages/pds/src/api/com/atproto/server/getAccountInviteCodes.ts b/packages/pds/src/api/com/atproto/server/getAccountInviteCodes.ts index e8a6d48af3f..8705ebfb9c2 100644 --- a/packages/pds/src/api/com/atproto/server/getAccountInviteCodes.ts +++ b/packages/pds/src/api/com/atproto/server/getAccountInviteCodes.ts @@ -1,9 +1,8 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { genInvCodes } from './util' -import { sql } from 'kysely' import { InvalidRequestError } from '@atproto/xrpc-server' -import { nullToZero } from '../../../../db/util' +import { countAll, nullToZero } from '../../../../db/util' export default function (server: Server, ctx: AppContext) { server.com.atproto.server.getAccountInviteCodes({ @@ -24,7 +23,7 @@ export default function (server: Server, ctx: AppContext) { qb .selectFrom('invite_code_use') .groupBy('code') - .select(['code', sql`count(*)`.as('uses')]), + .select(['code', countAll.as('uses')]), ) .selectFrom('invite_code') .leftJoin('use_count', 'use_count.code', 'invite_code.code') @@ -34,7 +33,7 @@ export default function (server: Server, ctx: AppContext) { 'invite_code.code as code', 'invite_code.availableUses as available', 'invite_code.disabled as disabled', - nullToZero(ctx.db, ref('use_count.uses')).as('uses'), + nullToZero(ref('use_count.uses')).as('uses'), ]) .execute(), ]) diff --git a/packages/pds/src/db/util.ts b/packages/pds/src/db/util.ts index ad820cab062..eb3640a7317 100644 --- a/packages/pds/src/db/util.ts +++ b/packages/pds/src/db/util.ts @@ -7,7 +7,6 @@ import { SqliteIntrospector, SqliteQueryCompiler, } from 'kysely' -import Database from '.' export const actorWhereClause = (actor: string) => { if (actor.startsWith('did:')) { @@ -28,12 +27,8 @@ export const softDeleted = (repoOrRecord: { takedownId: number | null }) => { export const countAll = sql`count(*)` -export const nullToZero = (db: Database, ref: DbRef) => { - if (db.dialect === 'pg') { - return sql`coalesce(${ref}, 0)` - } else { - return sql`ifnull(${ref}, 0)` - } +export const nullToZero = (ref: DbRef) => { + return sql`coalesce(${ref}, 0)` } export const dummyDialect = { From 504cac64a8fb32840bc7c2d26562912842d06f50 Mon Sep 17 00:00:00 2001 From: dholms Date: Wed, 5 Apr 2023 15:05:01 -0500 Subject: [PATCH 13/15] refactor & return usedBy + more details on getAccountInviteCodes --- .../com/atproto/admin/getInviteCodeUsage.json | 32 ------ .../com/atproto/admin/getInviteCodes.json | 2 +- .../atproto/server/getAccountInviteCodes.json | 28 +++-- packages/api/src/client/index.ts | 13 --- packages/api/src/client/lexicons.ts | 101 ++++++++---------- .../com/atproto/admin/getInviteCodeUsage.ts | 58 ---------- .../atproto/server/getAccountInviteCodes.ts | 41 +++++-- .../com/atproto/admin/getInviteCodeUsage.ts | 73 ------------- .../api/com/atproto/admin/getInviteCodes.ts | 44 +------- .../pds/src/api/com/atproto/admin/index.ts | 2 - .../atproto/server/getAccountInviteCodes.ts | 42 ++++---- packages/pds/src/lexicon/index.ts | 11 -- packages/pds/src/lexicon/lexicons.ts | 101 ++++++++---------- .../com/atproto/admin/getInviteCodeUsage.ts | 64 ----------- .../atproto/server/getAccountInviteCodes.ts | 41 +++++-- packages/pds/src/services/account/index.ts | 47 +++++++- packages/pds/tests/account.test.ts | 2 +- .../pds/tests/views/admin/invites.test.ts | 30 +----- 18 files changed, 253 insertions(+), 479 deletions(-) delete mode 100644 lexicons/com/atproto/admin/getInviteCodeUsage.json delete mode 100644 packages/api/src/client/types/com/atproto/admin/getInviteCodeUsage.ts delete mode 100644 packages/pds/src/api/com/atproto/admin/getInviteCodeUsage.ts delete mode 100644 packages/pds/src/lexicon/types/com/atproto/admin/getInviteCodeUsage.ts diff --git a/lexicons/com/atproto/admin/getInviteCodeUsage.json b/lexicons/com/atproto/admin/getInviteCodeUsage.json deleted file mode 100644 index 25135b297e5..00000000000 --- a/lexicons/com/atproto/admin/getInviteCodeUsage.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "lexicon": 1, - "id": "com.atproto.admin.getInviteCodeUsage", - "defs": { - "main": { - "type": "query", - "description": "High level stats about invite code usage", - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["total", "user", "admin"], - "properties": { - "total": { "type": "ref", "ref": "#codesDetail" }, - "user": { "type": "ref", "ref": "#codesDetail" }, - "admin": { "type": "ref", "ref": "#codesDetail" } - } - } - } - }, - "codesDetail": { - "type": "object", - "required": ["count", "available", "used", "disabled"], - "properties": { - "count": {"type": "integer"}, - "available": {"type": "integer"}, - "used": {"type": "integer"}, - "disabled": {"type": "integer"} - } - } - } -} diff --git a/lexicons/com/atproto/admin/getInviteCodes.json b/lexicons/com/atproto/admin/getInviteCodes.json index f5a510fd8d2..232767752e6 100644 --- a/lexicons/com/atproto/admin/getInviteCodes.json +++ b/lexicons/com/atproto/admin/getInviteCodes.json @@ -43,7 +43,7 @@ "available": {"type": "integer"}, "disabled": {"type": "boolean"}, "forAccount": {"type": "string"}, - "createdBy": {"type": "string", "format": "datetime"}, + "createdBy": {"type": "string"}, "createdAt": {"type": "string", "format": "datetime"}, "uses": { "type": "array", diff --git a/lexicons/com/atproto/server/getAccountInviteCodes.json b/lexicons/com/atproto/server/getAccountInviteCodes.json index 96bf71fd95d..753b5259bf9 100644 --- a/lexicons/com/atproto/server/getAccountInviteCodes.json +++ b/lexicons/com/atproto/server/getAccountInviteCodes.json @@ -22,7 +22,7 @@ "type": "array", "items": { "type": "ref", - "ref": "#invite" + "ref": "#codeDetail" } } } @@ -32,14 +32,28 @@ {"name": "DuplicateCreate"} ] }, - "invite": { + "codeDetail": { "type": "object", - "required": ["code", "available", "uses", "disabled"], + "required": ["code", "available", "disabled", "forAccount", "createdBy", "createdAt", "uses"], "properties": { - "code": { "type": "string" }, - "available": { "type": "integer" }, - "uses": { "type": "integer" }, - "disabled": { "type": "boolean" } + "code": {"type": "string"}, + "available": {"type": "integer"}, + "disabled": {"type": "boolean"}, + "forAccount": {"type": "string"}, + "createdBy": {"type": "string"}, + "createdAt": {"type": "string", "format": "datetime"}, + "uses": { + "type": "array", + "items": {"type": "ref", "ref": "#codeUse"} + } + } + }, + "codeUse": { + "type": "object", + "required": ["usedBy", "usedAt"], + "properties": { + "usedBy": {"type": "string", "format": "did"}, + "usedAt": {"type": "string", "format": "datetime"} } } } diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index 9432cea1987..e4e903618a8 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -9,7 +9,6 @@ import { schemas } from './lexicons' import { CID } from 'multiformats/cid' import * as ComAtprotoAdminDefs from './types/com/atproto/admin/defs' import * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes' -import * as ComAtprotoAdminGetInviteCodeUsage from './types/com/atproto/admin/getInviteCodeUsage' import * as ComAtprotoAdminGetInviteCodes from './types/com/atproto/admin/getInviteCodes' import * as ComAtprotoAdminGetModerationAction from './types/com/atproto/admin/getModerationAction' import * as ComAtprotoAdminGetModerationActions from './types/com/atproto/admin/getModerationActions' @@ -91,7 +90,6 @@ import * as AppBskyUnspeccedGetPopular from './types/app/bsky/unspecced/getPopul export * as ComAtprotoAdminDefs from './types/com/atproto/admin/defs' export * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes' -export * as ComAtprotoAdminGetInviteCodeUsage from './types/com/atproto/admin/getInviteCodeUsage' export * as ComAtprotoAdminGetInviteCodes from './types/com/atproto/admin/getInviteCodes' export * as ComAtprotoAdminGetModerationAction from './types/com/atproto/admin/getModerationAction' export * as ComAtprotoAdminGetModerationActions from './types/com/atproto/admin/getModerationActions' @@ -259,17 +257,6 @@ export class AdminNS { }) } - getInviteCodeUsage( - params?: ComAtprotoAdminGetInviteCodeUsage.QueryParams, - opts?: ComAtprotoAdminGetInviteCodeUsage.CallOptions, - ): Promise { - return this._service.xrpc - .call('com.atproto.admin.getInviteCodeUsage', params, undefined, opts) - .catch((e) => { - throw ComAtprotoAdminGetInviteCodeUsage.toKnownErr(e) - }) - } - getInviteCodes( params?: ComAtprotoAdminGetInviteCodes.QueryParams, opts?: ComAtprotoAdminGetInviteCodes.CallOptions, diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 156950eb5f3..2b52ec1c66d 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -561,55 +561,6 @@ export const schemaDict = { }, }, }, - ComAtprotoAdminGetInviteCodeUsage: { - lexicon: 1, - id: 'com.atproto.admin.getInviteCodeUsage', - defs: { - main: { - type: 'query', - description: 'High level stats about invite code usage', - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['total', 'user', 'admin'], - properties: { - total: { - type: 'ref', - ref: 'lex:com.atproto.admin.getInviteCodeUsage#codesDetail', - }, - user: { - type: 'ref', - ref: 'lex:com.atproto.admin.getInviteCodeUsage#codesDetail', - }, - admin: { - type: 'ref', - ref: 'lex:com.atproto.admin.getInviteCodeUsage#codesDetail', - }, - }, - }, - }, - }, - codesDetail: { - type: 'object', - required: ['count', 'available', 'used', 'disabled'], - properties: { - count: { - type: 'integer', - }, - available: { - type: 'integer', - }, - used: { - type: 'integer', - }, - disabled: { - type: 'integer', - }, - }, - }, - }, - }, ComAtprotoAdminGetInviteCodes: { lexicon: 1, id: 'com.atproto.admin.getInviteCodes', @@ -685,6 +636,7 @@ export const schemaDict = { }, createdAt: { type: 'string', + format: 'datetime', }, uses: { type: 'array', @@ -701,9 +653,11 @@ export const schemaDict = { properties: { usedBy: { type: 'string', + format: 'did', }, usedAt: { type: 'string', + format: 'datetime', }, }, }, @@ -2075,7 +2029,7 @@ export const schemaDict = { type: 'array', items: { type: 'ref', - ref: 'lex:com.atproto.server.getAccountInviteCodes#invite', + ref: 'lex:com.atproto.server.getAccountInviteCodes#codeDetail', }, }, }, @@ -2087,9 +2041,17 @@ export const schemaDict = { }, ], }, - invite: { + codeDetail: { type: 'object', - required: ['code', 'available', 'uses', 'disabled'], + required: [ + 'code', + 'available', + 'disabled', + 'forAccount', + 'createdBy', + 'createdAt', + 'uses', + ], properties: { code: { type: 'string', @@ -2097,12 +2059,40 @@ export const schemaDict = { available: { type: 'integer', }, - uses: { - type: 'integer', - }, disabled: { type: 'boolean', }, + forAccount: { + type: 'string', + }, + createdBy: { + type: 'string', + }, + createdAt: { + type: 'string', + format: 'datetime', + }, + uses: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.server.getAccountInviteCodes#codeUse', + }, + }, + }, + }, + codeUse: { + type: 'object', + required: ['usedBy', 'usedAt'], + properties: { + usedBy: { + type: 'string', + format: 'did', + }, + usedAt: { + type: 'string', + format: 'datetime', + }, }, }, }, @@ -4404,7 +4394,6 @@ export const lexicons: Lexicons = new Lexicons(schemas) export const ids = { ComAtprotoAdminDefs: 'com.atproto.admin.defs', ComAtprotoAdminDisableInviteCodes: 'com.atproto.admin.disableInviteCodes', - ComAtprotoAdminGetInviteCodeUsage: 'com.atproto.admin.getInviteCodeUsage', ComAtprotoAdminGetInviteCodes: 'com.atproto.admin.getInviteCodes', ComAtprotoAdminGetModerationAction: 'com.atproto.admin.getModerationAction', ComAtprotoAdminGetModerationActions: 'com.atproto.admin.getModerationActions', diff --git a/packages/api/src/client/types/com/atproto/admin/getInviteCodeUsage.ts b/packages/api/src/client/types/com/atproto/admin/getInviteCodeUsage.ts deleted file mode 100644 index 1463f2d00f3..00000000000 --- a/packages/api/src/client/types/com/atproto/admin/getInviteCodeUsage.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * GENERATED CODE - DO NOT MODIFY - */ -import { Headers, XRPCError } from '@atproto/xrpc' -import { ValidationResult, BlobRef } from '@atproto/lexicon' -import { isObj, hasProp } from '../../../../util' -import { lexicons } from '../../../../lexicons' -import { CID } from 'multiformats/cid' - -export interface QueryParams {} - -export type InputSchema = undefined - -export interface OutputSchema { - total: CodesDetail - user: CodesDetail - admin: CodesDetail - [k: string]: unknown -} - -export interface CallOptions { - headers?: Headers -} - -export interface Response { - success: boolean - headers: Headers - data: OutputSchema -} - -export function toKnownErr(e: any) { - if (e instanceof XRPCError) { - } - return e -} - -export interface CodesDetail { - count: number - available: number - used: number - disabled: number - [k: string]: unknown -} - -export function isCodesDetail(v: unknown): v is CodesDetail { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.getInviteCodeUsage#codesDetail' - ) -} - -export function validateCodesDetail(v: unknown): ValidationResult { - return lexicons.validate( - 'com.atproto.admin.getInviteCodeUsage#codesDetail', - v, - ) -} diff --git a/packages/api/src/client/types/com/atproto/server/getAccountInviteCodes.ts b/packages/api/src/client/types/com/atproto/server/getAccountInviteCodes.ts index 59fba85a4be..9aaf2159b75 100644 --- a/packages/api/src/client/types/com/atproto/server/getAccountInviteCodes.ts +++ b/packages/api/src/client/types/com/atproto/server/getAccountInviteCodes.ts @@ -15,7 +15,7 @@ export interface QueryParams { export type InputSchema = undefined export interface OutputSchema { - codes: Invite[] + codes: CodeDetail[] [k: string]: unknown } @@ -42,22 +42,49 @@ export function toKnownErr(e: any) { return e } -export interface Invite { +export interface CodeDetail { code: string available: number - uses: number disabled: boolean + forAccount: string + createdBy: string + createdAt: string + uses: CodeUse[] [k: string]: unknown } -export function isInvite(v: unknown): v is Invite { +export function isCodeDetail(v: unknown): v is CodeDetail { return ( isObj(v) && hasProp(v, '$type') && - v.$type === 'com.atproto.server.getAccountInviteCodes#invite' + v.$type === 'com.atproto.server.getAccountInviteCodes#codeDetail' ) } -export function validateInvite(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.server.getAccountInviteCodes#invite', v) +export function validateCodeDetail(v: unknown): ValidationResult { + return lexicons.validate( + 'com.atproto.server.getAccountInviteCodes#codeDetail', + v, + ) +} + +export interface CodeUse { + usedBy: string + usedAt: string + [k: string]: unknown +} + +export function isCodeUse(v: unknown): v is CodeUse { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.server.getAccountInviteCodes#codeUse' + ) +} + +export function validateCodeUse(v: unknown): ValidationResult { + return lexicons.validate( + 'com.atproto.server.getAccountInviteCodes#codeUse', + v, + ) } diff --git a/packages/pds/src/api/com/atproto/admin/getInviteCodeUsage.ts b/packages/pds/src/api/com/atproto/admin/getInviteCodeUsage.ts deleted file mode 100644 index 93a91276272..00000000000 --- a/packages/pds/src/api/com/atproto/admin/getInviteCodeUsage.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Server } from '../../../../lexicon' -import AppContext from '../../../../context' -import { countAll, nullToZero } from '../../../../db/util' - -export default function (server: Server, ctx: AppContext) { - server.com.atproto.admin.getInviteCodeUsage({ - auth: ctx.adminVerifier, - handler: async () => { - const ref = ctx.db.db.dynamic.ref - const res = await ctx.db.db - .with('use_count', (qb) => - qb - .selectFrom('invite_code_use') - .groupBy('code') - .select(['code', countAll.as('uses')]), - ) - .selectFrom('invite_code') - .leftJoin('use_count', 'use_count.code', 'invite_code.code') - .select([ - 'invite_code.createdBy as createdBy', - 'invite_code.availableUses as available', - 'invite_code.disabled as disabled', - nullToZero(ref('use_count.uses')).as('uses'), - ]) - .execute() - - const total = res.reduce(reducer, empty()) - const user = res - .filter((row) => row.createdBy !== 'admin') - .reduce(reducer, empty()) - const admin = res - .filter((row) => row.createdBy === 'admin') - .reduce(reducer, empty()) - - return { - encoding: 'application/json', - body: { - total, - user, - admin, - }, - } - }, - }) -} - -type CodesDetail = { - count: number - available: number - used: number - disabled: number -} - -type Row = { - available: number - disabled: 1 | 0 - uses: number -} - -const empty = () => ({ - count: 0, - available: 0, - used: 0, - disabled: 0, -}) - -const reducer = (acc: CodesDetail, cur: Row) => { - acc.count += 1 - acc.available += cur.available - acc.disabled += cur.disabled - acc.used += cur.uses - return acc -} diff --git a/packages/pds/src/api/com/atproto/admin/getInviteCodes.ts b/packages/pds/src/api/com/atproto/admin/getInviteCodes.ts index ca640a8e6a8..35413c2d5f4 100644 --- a/packages/pds/src/api/com/atproto/admin/getInviteCodes.ts +++ b/packages/pds/src/api/com/atproto/admin/getInviteCodes.ts @@ -7,11 +7,6 @@ import { GenericKeyset, paginate, } from '../../../../db/pagination' -import { - CodeDetail, - CodeUse, -} from '../../../../lexicon/types/com/atproto/admin/getInviteCodes' -import { countAll, nullToZero } from '../../../../db/util' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getInviteCodes({ @@ -19,25 +14,6 @@ export default function (server: Server, ctx: AppContext) { handler: async ({ params }) => { const { sort, limit, cursor } = params const ref = ctx.db.db.dynamic.ref - const innerBuilder = ctx.db.db - .with('use_count', (qb) => - qb - .selectFrom('invite_code_use') - .groupBy('code') - .select(['code', countAll.as('uses')]), - ) - .selectFrom('invite_code') - .leftJoin('use_count', 'invite_code.code', 'use_count.code') - .select([ - 'invite_code.code as code', - 'invite_code.availableUses as available', - 'invite_code.disabled as disabled', - 'invite_code.forUser as forAccount', - 'invite_code.createdBy as createdBy', - 'invite_code.createdAt as createdAt', - nullToZero(ref('use_count.uses')).as('uses'), - ]) - let keyset if (sort === 'recent') { keyset = new TimeCodeKeyset(ref('createdAt'), ref('code')) @@ -47,7 +23,9 @@ export default function (server: Server, ctx: AppContext) { throw new InvalidRequestError(`unknown sort method: ${sort}`) } - let builder = ctx.db.db.selectFrom(innerBuilder.as('codes')).selectAll() + const accntSrvc = ctx.services.account(ctx.db) + + let builder = accntSrvc.selectInviteCodesQb() builder = paginate(builder, { limit, cursor, @@ -56,23 +34,11 @@ export default function (server: Server, ctx: AppContext) { const res = await builder.execute() - const uses: Record = {} const codes = res.map((row) => row.code) - if (codes.length > 0) { - const usesRes = await ctx.db.db - .selectFrom('invite_code_use') - .where('code', 'in', codes) - .selectAll() - .execute() - for (const use of usesRes) { - const { code, usedBy, usedAt } = use - uses[code] ??= [] - uses[code].push({ usedBy, usedAt }) - } - } + const uses = await accntSrvc.getCodeUses(codes) const resultCursor = keyset.packFromResult(res) - const codeDetails: CodeDetail[] = res.map((row) => ({ + const codeDetails = res.map((row) => ({ ...row, disabled: row.disabled === 1, uses: uses[row.code] ?? [], diff --git a/packages/pds/src/api/com/atproto/admin/index.ts b/packages/pds/src/api/com/atproto/admin/index.ts index 5dfe99e7066..9991c5f7b80 100644 --- a/packages/pds/src/api/com/atproto/admin/index.ts +++ b/packages/pds/src/api/com/atproto/admin/index.ts @@ -11,7 +11,6 @@ import getModerationActions from './getModerationActions' import getModerationReport from './getModerationReport' import getModerationReports from './getModerationReports' import disableInviteCodes from './disableInviteCodes' -import getInviteCodeUsage from './getInviteCodeUsage' import getInviteCodes from './getInviteCodes' export default function (server: Server, ctx: AppContext) { @@ -26,6 +25,5 @@ export default function (server: Server, ctx: AppContext) { getModerationReport(server, ctx) getModerationReports(server, ctx) disableInviteCodes(server, ctx) - getInviteCodeUsage(server, ctx) getInviteCodes(server, ctx) } diff --git a/packages/pds/src/api/com/atproto/server/getAccountInviteCodes.ts b/packages/pds/src/api/com/atproto/server/getAccountInviteCodes.ts index 8705ebfb9c2..0225e98755e 100644 --- a/packages/pds/src/api/com/atproto/server/getAccountInviteCodes.ts +++ b/packages/pds/src/api/com/atproto/server/getAccountInviteCodes.ts @@ -2,7 +2,6 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { genInvCodes } from './util' import { InvalidRequestError } from '@atproto/xrpc-server' -import { countAll, nullToZero } from '../../../../db/util' export default function (server: Server, ctx: AppContext) { server.com.atproto.server.getAccountInviteCodes({ @@ -11,36 +10,26 @@ export default function (server: Server, ctx: AppContext) { const requester = auth.credentials.did const { includeUsed, createAvailable } = params - const ref = ctx.db.db.dynamic.ref + const accntSrvc = ctx.services.account(ctx.db) + const [user, userCodesRes] = await Promise.all([ ctx.db.db .selectFrom('user_account') .where('did', '=', requester) .select('createdAt') .executeTakeFirstOrThrow(), - ctx.db.db - .with('use_count', (qb) => - qb - .selectFrom('invite_code_use') - .groupBy('code') - .select(['code', countAll.as('uses')]), - ) - .selectFrom('invite_code') - .leftJoin('use_count', 'use_count.code', 'invite_code.code') - .where('forUser', '=', requester) - .groupBy('invite_code.code') - .select([ - 'invite_code.code as code', - 'invite_code.availableUses as available', - 'invite_code.disabled as disabled', - nullToZero(ref('use_count.uses')).as('uses'), - ]) + accntSrvc + .selectInviteCodesQb() + .where('forAccount', '=', requester) .execute(), ]) const userCodes = userCodesRes.map((row) => ({ ...row, disabled: row.disabled === 1, })) + const codeUses = await accntSrvc.getCodeUses( + userCodes.map((row) => row.code), + ) const unusedCodes = userCodes.filter((row) => row.available > row.uses) let created: string[] = [] @@ -48,6 +37,7 @@ export default function (server: Server, ctx: AppContext) { // if the user wishes to create available codes & the server allows that, // we determine the number to create by dividing their account lifetime by the interval at which they can create codes // we allow a max of 5 open codes at a given time + const now = new Date().toISOString() if (createAvailable && ctx.cfg.userInviteInterval !== null) { const accountLifespan = Date.now() - new Date(user.createdAt).getTime() const couldCreate = Math.floor( @@ -62,7 +52,7 @@ export default function (server: Server, ctx: AppContext) { disabled: 0 as const, forUser: requester, createdBy: requester, - createdAt: new Date().toISOString(), + createdAt: now, })) await ctx.db.transaction(async (dbTxn) => { await dbTxn.db.insertInto('invite_code').values(rows).execute() @@ -81,13 +71,21 @@ export default function (server: Server, ctx: AppContext) { } } + const preexisting = includeUsed ? userCodes : unusedCodes + const toReturn = [ - ...(includeUsed ? userCodes : unusedCodes), + ...preexisting.map((row) => ({ + ...row, + uses: codeUses[row.code] ?? [], + })), ...created.map((code) => ({ code: code, available: 1, - uses: 0, disabled: false, + forAccount: requester, + createdBy: requester, + createdAt: now, + uses: [], })), ] diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index 6035f9a83ca..d99d789c2ff 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -10,7 +10,6 @@ import { } from '@atproto/xrpc-server' import { schemas } from './lexicons' import * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes' -import * as ComAtprotoAdminGetInviteCodeUsage from './types/com/atproto/admin/getInviteCodeUsage' import * as ComAtprotoAdminGetInviteCodes from './types/com/atproto/admin/getInviteCodes' import * as ComAtprotoAdminGetModerationAction from './types/com/atproto/admin/getModerationAction' import * as ComAtprotoAdminGetModerationActions from './types/com/atproto/admin/getModerationActions' @@ -149,16 +148,6 @@ export class AdminNS { return this._server.xrpc.method(nsid, cfg) } - getInviteCodeUsage( - cfg: ConfigOf< - AV, - ComAtprotoAdminGetInviteCodeUsage.Handler> - >, - ) { - const nsid = 'com.atproto.admin.getInviteCodeUsage' // @ts-ignore - return this._server.xrpc.method(nsid, cfg) - } - getInviteCodes( cfg: ConfigOf>>, ) { diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 156950eb5f3..2b52ec1c66d 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -561,55 +561,6 @@ export const schemaDict = { }, }, }, - ComAtprotoAdminGetInviteCodeUsage: { - lexicon: 1, - id: 'com.atproto.admin.getInviteCodeUsage', - defs: { - main: { - type: 'query', - description: 'High level stats about invite code usage', - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['total', 'user', 'admin'], - properties: { - total: { - type: 'ref', - ref: 'lex:com.atproto.admin.getInviteCodeUsage#codesDetail', - }, - user: { - type: 'ref', - ref: 'lex:com.atproto.admin.getInviteCodeUsage#codesDetail', - }, - admin: { - type: 'ref', - ref: 'lex:com.atproto.admin.getInviteCodeUsage#codesDetail', - }, - }, - }, - }, - }, - codesDetail: { - type: 'object', - required: ['count', 'available', 'used', 'disabled'], - properties: { - count: { - type: 'integer', - }, - available: { - type: 'integer', - }, - used: { - type: 'integer', - }, - disabled: { - type: 'integer', - }, - }, - }, - }, - }, ComAtprotoAdminGetInviteCodes: { lexicon: 1, id: 'com.atproto.admin.getInviteCodes', @@ -685,6 +636,7 @@ export const schemaDict = { }, createdAt: { type: 'string', + format: 'datetime', }, uses: { type: 'array', @@ -701,9 +653,11 @@ export const schemaDict = { properties: { usedBy: { type: 'string', + format: 'did', }, usedAt: { type: 'string', + format: 'datetime', }, }, }, @@ -2075,7 +2029,7 @@ export const schemaDict = { type: 'array', items: { type: 'ref', - ref: 'lex:com.atproto.server.getAccountInviteCodes#invite', + ref: 'lex:com.atproto.server.getAccountInviteCodes#codeDetail', }, }, }, @@ -2087,9 +2041,17 @@ export const schemaDict = { }, ], }, - invite: { + codeDetail: { type: 'object', - required: ['code', 'available', 'uses', 'disabled'], + required: [ + 'code', + 'available', + 'disabled', + 'forAccount', + 'createdBy', + 'createdAt', + 'uses', + ], properties: { code: { type: 'string', @@ -2097,12 +2059,40 @@ export const schemaDict = { available: { type: 'integer', }, - uses: { - type: 'integer', - }, disabled: { type: 'boolean', }, + forAccount: { + type: 'string', + }, + createdBy: { + type: 'string', + }, + createdAt: { + type: 'string', + format: 'datetime', + }, + uses: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.server.getAccountInviteCodes#codeUse', + }, + }, + }, + }, + codeUse: { + type: 'object', + required: ['usedBy', 'usedAt'], + properties: { + usedBy: { + type: 'string', + format: 'did', + }, + usedAt: { + type: 'string', + format: 'datetime', + }, }, }, }, @@ -4404,7 +4394,6 @@ export const lexicons: Lexicons = new Lexicons(schemas) export const ids = { ComAtprotoAdminDefs: 'com.atproto.admin.defs', ComAtprotoAdminDisableInviteCodes: 'com.atproto.admin.disableInviteCodes', - ComAtprotoAdminGetInviteCodeUsage: 'com.atproto.admin.getInviteCodeUsage', ComAtprotoAdminGetInviteCodes: 'com.atproto.admin.getInviteCodes', ComAtprotoAdminGetModerationAction: 'com.atproto.admin.getModerationAction', ComAtprotoAdminGetModerationActions: 'com.atproto.admin.getModerationActions', diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/getInviteCodeUsage.ts b/packages/pds/src/lexicon/types/com/atproto/admin/getInviteCodeUsage.ts deleted file mode 100644 index c8d80c57fee..00000000000 --- a/packages/pds/src/lexicon/types/com/atproto/admin/getInviteCodeUsage.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * GENERATED CODE - DO NOT MODIFY - */ -import express from 'express' -import { ValidationResult, BlobRef } from '@atproto/lexicon' -import { lexicons } from '../../../../lexicons' -import { isObj, hasProp } from '../../../../util' -import { CID } from 'multiformats/cid' -import { HandlerAuth } from '@atproto/xrpc-server' - -export interface QueryParams {} - -export type InputSchema = undefined - -export interface OutputSchema { - total: CodesDetail - user: CodesDetail - admin: CodesDetail - [k: string]: unknown -} - -export type HandlerInput = undefined - -export interface HandlerSuccess { - encoding: 'application/json' - body: OutputSchema -} - -export interface HandlerError { - status: number - message?: string -} - -export type HandlerOutput = HandlerError | HandlerSuccess -export type Handler = (ctx: { - auth: HA - params: QueryParams - input: HandlerInput - req: express.Request - res: express.Response -}) => Promise | HandlerOutput - -export interface CodesDetail { - count: number - available: number - used: number - disabled: number - [k: string]: unknown -} - -export function isCodesDetail(v: unknown): v is CodesDetail { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.getInviteCodeUsage#codesDetail' - ) -} - -export function validateCodesDetail(v: unknown): ValidationResult { - return lexicons.validate( - 'com.atproto.admin.getInviteCodeUsage#codesDetail', - v, - ) -} diff --git a/packages/pds/src/lexicon/types/com/atproto/server/getAccountInviteCodes.ts b/packages/pds/src/lexicon/types/com/atproto/server/getAccountInviteCodes.ts index e701971c4ae..9621431d37d 100644 --- a/packages/pds/src/lexicon/types/com/atproto/server/getAccountInviteCodes.ts +++ b/packages/pds/src/lexicon/types/com/atproto/server/getAccountInviteCodes.ts @@ -16,7 +16,7 @@ export interface QueryParams { export type InputSchema = undefined export interface OutputSchema { - codes: Invite[] + codes: CodeDetail[] [k: string]: unknown } @@ -42,22 +42,49 @@ export type Handler = (ctx: { res: express.Response }) => Promise | HandlerOutput -export interface Invite { +export interface CodeDetail { code: string available: number - uses: number disabled: boolean + forAccount: string + createdBy: string + createdAt: string + uses: CodeUse[] [k: string]: unknown } -export function isInvite(v: unknown): v is Invite { +export function isCodeDetail(v: unknown): v is CodeDetail { return ( isObj(v) && hasProp(v, '$type') && - v.$type === 'com.atproto.server.getAccountInviteCodes#invite' + v.$type === 'com.atproto.server.getAccountInviteCodes#codeDetail' ) } -export function validateInvite(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.server.getAccountInviteCodes#invite', v) +export function validateCodeDetail(v: unknown): ValidationResult { + return lexicons.validate( + 'com.atproto.server.getAccountInviteCodes#codeDetail', + v, + ) +} + +export interface CodeUse { + usedBy: string + usedAt: string + [k: string]: unknown +} + +export function isCodeUse(v: unknown): v is CodeUse { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.server.getAccountInviteCodes#codeUse' + ) +} + +export function validateCodeUse(v: unknown): ValidationResult { + return lexicons.validate( + 'com.atproto.server.getAccountInviteCodes#codeUse', + v, + ) } diff --git a/packages/pds/src/services/account/index.ts b/packages/pds/src/services/account/index.ts index edba837729c..994e4129983 100644 --- a/packages/pds/src/services/account/index.ts +++ b/packages/pds/src/services/account/index.ts @@ -5,7 +5,7 @@ import * as scrypt from '../../db/scrypt' import { UserAccount } from '../../db/tables/user-account' import { DidHandle } from '../../db/tables/did-handle' import { RepoRoot } from '../../db/tables/repo-root' -import { notSoftDeletedClause } from '../../db/util' +import { countAll, notSoftDeletedClause, nullToZero } from '../../db/util' import { getUserSearchQueryPg, getUserSearchQuerySqlite } from '../util/search' import { paginate, TimeCidKeyset } from '../../db/pagination' import { sequenceHandleUpdate } from '../../sequencer' @@ -248,6 +248,51 @@ export class AccountService { .execute(), ]) } + + selectInviteCodesQb() { + const ref = this.db.db.dynamic.ref + const builder = this.db.db + .with('use_count', (qb) => + qb + .selectFrom('invite_code_use') + .groupBy('code') + .select(['code', countAll.as('uses')]), + ) + .selectFrom('invite_code') + .leftJoin('use_count', 'invite_code.code', 'use_count.code') + .select([ + 'invite_code.code as code', + 'invite_code.availableUses as available', + 'invite_code.disabled as disabled', + 'invite_code.forUser as forAccount', + 'invite_code.createdBy as createdBy', + 'invite_code.createdAt as createdAt', + nullToZero(ref('use_count.uses')).as('uses'), + ]) + return this.db.db.selectFrom(builder.as('codes')).selectAll() + } + + async getCodeUses(codes: string[]): Promise> { + const uses: Record = {} + if (codes.length > 0) { + const usesRes = await this.db.db + .selectFrom('invite_code_use') + .where('code', 'in', codes) + .selectAll() + .execute() + for (const use of usesRes) { + const { code, usedBy, usedAt } = use + uses[code] ??= [] + uses[code].push({ usedBy, usedAt }) + } + } + return uses + } +} + +type CodeUse = { + usedBy: string + usedAt: string } export class UserAlreadyExistsError extends Error {} diff --git a/packages/pds/tests/account.test.ts b/packages/pds/tests/account.test.ts index 31d8a2aef2b..87676239460 100644 --- a/packages/pds/tests/account.test.ts +++ b/packages/pds/tests/account.test.ts @@ -485,7 +485,7 @@ describe('account', () => { .values( res1.data.codes.map((code) => ({ code: code.code, - usedBy: 'blah', + usedBy: 'did:example:test', usedAt: new Date().toISOString(), })), ) diff --git a/packages/pds/tests/views/admin/invites.test.ts b/packages/pds/tests/views/admin/invites.test.ts index 18bb1272d23..deef22b1c8e 100644 --- a/packages/pds/tests/views/admin/invites.test.ts +++ b/packages/pds/tests/views/admin/invites.test.ts @@ -1,9 +1,6 @@ import AtpAgent from '@atproto/api' -import { runTestServer, forSnapshot, CloseFn, adminAuth } from '../../_util' -import { SeedClient } from '../../seeds/client' -import basicSeed from '../../seeds/basic' +import { runTestServer, CloseFn, adminAuth } from '../../_util' import { randomStr } from '@atproto/crypto' -import { wait } from '@atproto/common' describe('pds admin invite views', () => { let agent: AtpAgent @@ -155,29 +152,4 @@ describe('pds admin invite views', () => { const combined = [...first.data.codes, ...second.data.codes] expect(combined).toEqual(full.data.codes) }) - - it('gets high level invite code info', async () => { - const res = await agent.api.com.atproto.admin.getInviteCodeUsage( - {}, - { headers: { authorization: adminAuth() } }, - ) - expect(res.data.admin).toEqual({ - count: 2, - available: 15, - used: 2, - disabled: 1, - }) - expect(res.data.user).toEqual({ - count: 10, - available: 10, - used: 2, - disabled: 5, - }) - expect(res.data.total).toEqual({ - count: 12, - available: 25, - used: 4, - disabled: 6, - }) - }) }) From 8cb3a6529092c0a617272ede2d21db7281259cf7 Mon Sep 17 00:00:00 2001 From: dholms Date: Wed, 5 Apr 2023 15:46:27 -0500 Subject: [PATCH 14/15] adding invite info into moderation views --- lexicons/com/atproto/admin/defs.json | 14 +- .../com/atproto/admin/getInviteCodes.json | 26 +-- lexicons/com/atproto/admin/searchRepos.json | 1 + lexicons/com/atproto/server/defs.json | 30 +++ .../atproto/server/getAccountInviteCodes.json | 26 +-- packages/api/src/client/index.ts | 2 + packages/api/src/client/lexicons.ts | 198 ++++++++---------- .../client/types/com/atproto/admin/defs.ts | 5 + .../types/com/atproto/admin/getInviteCodes.ts | 44 +--- .../types/com/atproto/admin/searchRepos.ts | 1 + .../client/types/com/atproto/server/defs.ts | 48 +++++ .../atproto/server/getAccountInviteCodes.ts | 50 +---- .../api/com/atproto/admin/getInviteCodes.ts | 2 +- .../atproto/server/getAccountInviteCodes.ts | 21 +- packages/pds/src/lexicon/lexicons.ts | 198 ++++++++---------- .../lexicon/types/com/atproto/admin/defs.ts | 5 + .../types/com/atproto/admin/getInviteCodes.ts | 44 +--- .../types/com/atproto/admin/searchRepos.ts | 1 + .../lexicon/types/com/atproto/server/defs.ts | 48 +++++ .../atproto/server/getAccountInviteCodes.ts | 50 +---- packages/pds/src/services/account/index.ts | 63 +++++- packages/pds/src/services/moderation/views.ts | 7 +- 22 files changed, 413 insertions(+), 471 deletions(-) create mode 100644 lexicons/com/atproto/server/defs.json create mode 100644 packages/api/src/client/types/com/atproto/server/defs.ts create mode 100644 packages/pds/src/lexicon/types/com/atproto/server/defs.ts diff --git a/lexicons/com/atproto/admin/defs.json b/lexicons/com/atproto/admin/defs.json index 8bba1c909a7..1c02c89ed6f 100644 --- a/lexicons/com/atproto/admin/defs.json +++ b/lexicons/com/atproto/admin/defs.json @@ -128,7 +128,12 @@ "email": {"type": "string"}, "relatedRecords": {"type": "array", "items": {"type": "unknown"}}, "indexedAt": {"type": "string", "format": "datetime"}, - "moderation": {"type": "ref", "ref": "#moderation"} + "moderation": {"type": "ref", "ref": "#moderation"}, + "invitedBy": {"type": "ref", "ref": "com.atproto.server.defs#inviteCode"}, + "invites": { + "type": "array", + "items": {"type": "ref", "ref": "com.atproto.server.defs#inviteCode"} + } } }, "repoViewDetail": { @@ -140,7 +145,12 @@ "email": {"type": "string"}, "relatedRecords": {"type": "array", "items": {"type": "unknown"}}, "indexedAt": {"type": "string", "format": "datetime"}, - "moderation": {"type": "ref", "ref": "#moderationDetail"} + "moderation": {"type": "ref", "ref": "#moderationDetail"}, + "invitedBy": {"type": "ref", "ref": "com.atproto.server.defs#inviteCode"}, + "invites": { + "type": "array", + "items": {"type": "ref", "ref": "com.atproto.server.defs#inviteCode"} + } } }, "repoRef": { diff --git a/lexicons/com/atproto/admin/getInviteCodes.json b/lexicons/com/atproto/admin/getInviteCodes.json index 232767752e6..c74a6d09bab 100644 --- a/lexicons/com/atproto/admin/getInviteCodes.json +++ b/lexicons/com/atproto/admin/getInviteCodes.json @@ -29,35 +29,11 @@ "cursor": {"type": "string"}, "codes": { "type": "array", - "items": {"type": "ref", "ref": "#codeDetail"} + "items": {"type": "ref", "ref": "com.atproto.server.defs#inviteCode"} } } } } - }, - "codeDetail": { - "type": "object", - "required": ["code", "available", "disabled", "forAccount", "createdBy", "createdAt", "uses"], - "properties": { - "code": {"type": "string"}, - "available": {"type": "integer"}, - "disabled": {"type": "boolean"}, - "forAccount": {"type": "string"}, - "createdBy": {"type": "string"}, - "createdAt": {"type": "string", "format": "datetime"}, - "uses": { - "type": "array", - "items": {"type": "ref", "ref": "#codeUse"} - } - } - }, - "codeUse": { - "type": "object", - "required": ["usedBy", "usedAt"], - "properties": { - "usedBy": {"type": "string", "format": "did"}, - "usedAt": {"type": "string", "format": "datetime"} - } } } } diff --git a/lexicons/com/atproto/admin/searchRepos.json b/lexicons/com/atproto/admin/searchRepos.json index a2750c32f87..8955866ff5f 100644 --- a/lexicons/com/atproto/admin/searchRepos.json +++ b/lexicons/com/atproto/admin/searchRepos.json @@ -9,6 +9,7 @@ "type": "params", "properties": { "term": {"type": "string"}, + "invitedBy": {"type": "string"}, "limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50}, "cursor": {"type": "string"} } diff --git a/lexicons/com/atproto/server/defs.json b/lexicons/com/atproto/server/defs.json new file mode 100644 index 00000000000..beb9954bb5b --- /dev/null +++ b/lexicons/com/atproto/server/defs.json @@ -0,0 +1,30 @@ +{ + "lexicon": 1, + "id": "com.atproto.server.defs", + "defs": { + "inviteCode": { + "type": "object", + "required": ["code", "available", "disabled", "forAccount", "createdBy", "createdAt", "uses"], + "properties": { + "code": {"type": "string"}, + "available": {"type": "integer"}, + "disabled": {"type": "boolean"}, + "forAccount": {"type": "string"}, + "createdBy": {"type": "string"}, + "createdAt": {"type": "string", "format": "datetime"}, + "uses": { + "type": "array", + "items": {"type": "ref", "ref": "#inviteCodeUse"} + } + } + }, + "inviteCodeUse": { + "type": "object", + "required": ["usedBy", "usedAt"], + "properties": { + "usedBy": {"type": "string", "format": "did"}, + "usedAt": {"type": "string", "format": "datetime"} + } + } + } +} diff --git a/lexicons/com/atproto/server/getAccountInviteCodes.json b/lexicons/com/atproto/server/getAccountInviteCodes.json index 753b5259bf9..ff6e4d6d1cb 100644 --- a/lexicons/com/atproto/server/getAccountInviteCodes.json +++ b/lexicons/com/atproto/server/getAccountInviteCodes.json @@ -22,7 +22,7 @@ "type": "array", "items": { "type": "ref", - "ref": "#codeDetail" + "ref": "com.atproto.server.defs#inviteCode" } } } @@ -31,30 +31,6 @@ "errors": [ {"name": "DuplicateCreate"} ] - }, - "codeDetail": { - "type": "object", - "required": ["code", "available", "disabled", "forAccount", "createdBy", "createdAt", "uses"], - "properties": { - "code": {"type": "string"}, - "available": {"type": "integer"}, - "disabled": {"type": "boolean"}, - "forAccount": {"type": "string"}, - "createdBy": {"type": "string"}, - "createdAt": {"type": "string", "format": "datetime"}, - "uses": { - "type": "array", - "items": {"type": "ref", "ref": "#codeUse"} - } - } - }, - "codeUse": { - "type": "object", - "required": ["usedBy", "usedAt"], - "properties": { - "usedBy": {"type": "string", "format": "did"}, - "usedAt": {"type": "string", "format": "datetime"} - } } } } diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index e4e903618a8..2d3905448ef 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -36,6 +36,7 @@ import * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob' import * as ComAtprotoServerCreateAccount from './types/com/atproto/server/createAccount' import * as ComAtprotoServerCreateInviteCode from './types/com/atproto/server/createInviteCode' import * as ComAtprotoServerCreateSession from './types/com/atproto/server/createSession' +import * as ComAtprotoServerDefs from './types/com/atproto/server/defs' import * as ComAtprotoServerDeleteAccount from './types/com/atproto/server/deleteAccount' import * as ComAtprotoServerDeleteSession from './types/com/atproto/server/deleteSession' import * as ComAtprotoServerDescribeServer from './types/com/atproto/server/describeServer' @@ -117,6 +118,7 @@ export * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob' export * as ComAtprotoServerCreateAccount from './types/com/atproto/server/createAccount' export * as ComAtprotoServerCreateInviteCode from './types/com/atproto/server/createInviteCode' export * as ComAtprotoServerCreateSession from './types/com/atproto/server/createSession' +export * as ComAtprotoServerDefs from './types/com/atproto/server/defs' export * as ComAtprotoServerDeleteAccount from './types/com/atproto/server/deleteAccount' export * as ComAtprotoServerDeleteSession from './types/com/atproto/server/deleteSession' export * as ComAtprotoServerDescribeServer from './types/com/atproto/server/describeServer' diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 2b52ec1c66d..e4045487326 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -299,6 +299,17 @@ export const schemaDict = { type: 'ref', ref: 'lex:com.atproto.admin.defs#moderation', }, + invitedBy: { + type: 'ref', + ref: 'lex:com.atproto.server.defs#inviteCode', + }, + invites: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.server.defs#inviteCode', + }, + }, }, }, repoViewDetail: { @@ -336,6 +347,17 @@ export const schemaDict = { type: 'ref', ref: 'lex:com.atproto.admin.defs#moderationDetail', }, + invitedBy: { + type: 'ref', + ref: 'lex:com.atproto.server.defs#inviteCode', + }, + invites: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.server.defs#inviteCode', + }, + }, }, }, repoRef: { @@ -600,67 +622,13 @@ export const schemaDict = { type: 'array', items: { type: 'ref', - ref: 'lex:com.atproto.admin.getInviteCodes#codeDetail', + ref: 'lex:com.atproto.server.defs#inviteCode', }, }, }, }, }, }, - codeDetail: { - type: 'object', - required: [ - 'code', - 'available', - 'disabled', - 'forAccount', - 'createdBy', - 'createdAt', - 'uses', - ], - properties: { - code: { - type: 'string', - }, - available: { - type: 'integer', - }, - disabled: { - type: 'boolean', - }, - forAccount: { - type: 'string', - }, - createdBy: { - type: 'string', - }, - createdAt: { - type: 'string', - format: 'datetime', - }, - uses: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:com.atproto.admin.getInviteCodes#codeUse', - }, - }, - }, - }, - codeUse: { - type: 'object', - required: ['usedBy', 'usedAt'], - properties: { - usedBy: { - type: 'string', - format: 'did', - }, - usedAt: { - type: 'string', - format: 'datetime', - }, - }, - }, }, }, ComAtprotoAdminGetModerationAction: { @@ -956,6 +924,9 @@ export const schemaDict = { term: { type: 'string', }, + invitedBy: { + type: 'string', + }, limit: { type: 'integer', minimum: 1, @@ -1908,6 +1879,66 @@ export const schemaDict = { }, }, }, + ComAtprotoServerDefs: { + lexicon: 1, + id: 'com.atproto.server.defs', + defs: { + inviteCode: { + type: 'object', + required: [ + 'code', + 'available', + 'disabled', + 'forAccount', + 'createdBy', + 'createdAt', + 'uses', + ], + properties: { + code: { + type: 'string', + }, + available: { + type: 'integer', + }, + disabled: { + type: 'boolean', + }, + forAccount: { + type: 'string', + }, + createdBy: { + type: 'string', + }, + createdAt: { + type: 'string', + format: 'datetime', + }, + uses: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.server.defs#inviteCodeUse', + }, + }, + }, + }, + inviteCodeUse: { + type: 'object', + required: ['usedBy', 'usedAt'], + properties: { + usedBy: { + type: 'string', + format: 'did', + }, + usedAt: { + type: 'string', + format: 'datetime', + }, + }, + }, + }, + }, ComAtprotoServerDeleteAccount: { lexicon: 1, id: 'com.atproto.server.deleteAccount', @@ -2029,7 +2060,7 @@ export const schemaDict = { type: 'array', items: { type: 'ref', - ref: 'lex:com.atproto.server.getAccountInviteCodes#codeDetail', + ref: 'lex:com.atproto.server.defs#inviteCode', }, }, }, @@ -2041,60 +2072,6 @@ export const schemaDict = { }, ], }, - codeDetail: { - type: 'object', - required: [ - 'code', - 'available', - 'disabled', - 'forAccount', - 'createdBy', - 'createdAt', - 'uses', - ], - properties: { - code: { - type: 'string', - }, - available: { - type: 'integer', - }, - disabled: { - type: 'boolean', - }, - forAccount: { - type: 'string', - }, - createdBy: { - type: 'string', - }, - createdAt: { - type: 'string', - format: 'datetime', - }, - uses: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:com.atproto.server.getAccountInviteCodes#codeUse', - }, - }, - }, - }, - codeUse: { - type: 'object', - required: ['usedBy', 'usedAt'], - properties: { - usedBy: { - type: 'string', - format: 'did', - }, - usedAt: { - type: 'string', - format: 'datetime', - }, - }, - }, }, }, ComAtprotoServerGetSession: { @@ -4423,6 +4400,7 @@ export const ids = { ComAtprotoServerCreateAccount: 'com.atproto.server.createAccount', ComAtprotoServerCreateInviteCode: 'com.atproto.server.createInviteCode', ComAtprotoServerCreateSession: 'com.atproto.server.createSession', + ComAtprotoServerDefs: 'com.atproto.server.defs', ComAtprotoServerDeleteAccount: 'com.atproto.server.deleteAccount', ComAtprotoServerDeleteSession: 'com.atproto.server.deleteSession', ComAtprotoServerDescribeServer: 'com.atproto.server.describeServer', diff --git a/packages/api/src/client/types/com/atproto/admin/defs.ts b/packages/api/src/client/types/com/atproto/admin/defs.ts index e96de2bd41f..8d6cc65c27b 100644 --- a/packages/api/src/client/types/com/atproto/admin/defs.ts +++ b/packages/api/src/client/types/com/atproto/admin/defs.ts @@ -7,6 +7,7 @@ import { lexicons } from '../../../../lexicons' import { CID } from 'multiformats/cid' import * as ComAtprotoRepoStrongRef from '../repo/strongRef' import * as ComAtprotoModerationDefs from '../moderation/defs' +import * as ComAtprotoServerDefs from '../server/defs' export interface ActionView { id: number @@ -167,6 +168,8 @@ export interface RepoView { relatedRecords: {}[] indexedAt: string moderation: Moderation + invitedBy?: ComAtprotoServerDefs.InviteCode + invites?: ComAtprotoServerDefs.InviteCode[] [k: string]: unknown } @@ -189,6 +192,8 @@ export interface RepoViewDetail { relatedRecords: {}[] indexedAt: string moderation: ModerationDetail + invitedBy?: ComAtprotoServerDefs.InviteCode + invites?: ComAtprotoServerDefs.InviteCode[] [k: string]: unknown } diff --git a/packages/api/src/client/types/com/atproto/admin/getInviteCodes.ts b/packages/api/src/client/types/com/atproto/admin/getInviteCodes.ts index e2d55bb45ef..eb5604839d7 100644 --- a/packages/api/src/client/types/com/atproto/admin/getInviteCodes.ts +++ b/packages/api/src/client/types/com/atproto/admin/getInviteCodes.ts @@ -6,6 +6,7 @@ import { ValidationResult, BlobRef } from '@atproto/lexicon' import { isObj, hasProp } from '../../../../util' import { lexicons } from '../../../../lexicons' import { CID } from 'multiformats/cid' +import * as ComAtprotoServerDefs from '../server/defs' export interface QueryParams { sort?: 'recent' | 'usage' | (string & {}) @@ -17,7 +18,7 @@ export type InputSchema = undefined export interface OutputSchema { cursor?: string - codes: CodeDetail[] + codes: ComAtprotoServerDefs.InviteCode[] [k: string]: unknown } @@ -36,44 +37,3 @@ export function toKnownErr(e: any) { } return e } - -export interface CodeDetail { - code: string - available: number - disabled: boolean - forAccount: string - createdBy: string - createdAt: string - uses: CodeUse[] - [k: string]: unknown -} - -export function isCodeDetail(v: unknown): v is CodeDetail { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.getInviteCodes#codeDetail' - ) -} - -export function validateCodeDetail(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.getInviteCodes#codeDetail', v) -} - -export interface CodeUse { - usedBy: string - usedAt: string - [k: string]: unknown -} - -export function isCodeUse(v: unknown): v is CodeUse { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.getInviteCodes#codeUse' - ) -} - -export function validateCodeUse(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.getInviteCodes#codeUse', v) -} diff --git a/packages/api/src/client/types/com/atproto/admin/searchRepos.ts b/packages/api/src/client/types/com/atproto/admin/searchRepos.ts index 10f24584f1c..a43e0ee7322 100644 --- a/packages/api/src/client/types/com/atproto/admin/searchRepos.ts +++ b/packages/api/src/client/types/com/atproto/admin/searchRepos.ts @@ -10,6 +10,7 @@ import * as ComAtprotoAdminDefs from './defs' export interface QueryParams { term?: string + invitedBy?: string limit?: number cursor?: string } diff --git a/packages/api/src/client/types/com/atproto/server/defs.ts b/packages/api/src/client/types/com/atproto/server/defs.ts new file mode 100644 index 00000000000..e6be3798909 --- /dev/null +++ b/packages/api/src/client/types/com/atproto/server/defs.ts @@ -0,0 +1,48 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { isObj, hasProp } from '../../../../util' +import { lexicons } from '../../../../lexicons' +import { CID } from 'multiformats/cid' + +export interface InviteCode { + code: string + available: number + disabled: boolean + forAccount: string + createdBy: string + createdAt: string + uses: InviteCodeUse[] + [k: string]: unknown +} + +export function isInviteCode(v: unknown): v is InviteCode { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.server.defs#inviteCode' + ) +} + +export function validateInviteCode(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.server.defs#inviteCode', v) +} + +export interface InviteCodeUse { + usedBy: string + usedAt: string + [k: string]: unknown +} + +export function isInviteCodeUse(v: unknown): v is InviteCodeUse { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.server.defs#inviteCodeUse' + ) +} + +export function validateInviteCodeUse(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.server.defs#inviteCodeUse', v) +} diff --git a/packages/api/src/client/types/com/atproto/server/getAccountInviteCodes.ts b/packages/api/src/client/types/com/atproto/server/getAccountInviteCodes.ts index 9aaf2159b75..451d7800f21 100644 --- a/packages/api/src/client/types/com/atproto/server/getAccountInviteCodes.ts +++ b/packages/api/src/client/types/com/atproto/server/getAccountInviteCodes.ts @@ -6,6 +6,7 @@ import { ValidationResult, BlobRef } from '@atproto/lexicon' import { isObj, hasProp } from '../../../../util' import { lexicons } from '../../../../lexicons' import { CID } from 'multiformats/cid' +import * as ComAtprotoServerDefs from './defs' export interface QueryParams { includeUsed?: boolean @@ -15,7 +16,7 @@ export interface QueryParams { export type InputSchema = undefined export interface OutputSchema { - codes: CodeDetail[] + codes: ComAtprotoServerDefs.InviteCode[] [k: string]: unknown } @@ -41,50 +42,3 @@ export function toKnownErr(e: any) { } return e } - -export interface CodeDetail { - code: string - available: number - disabled: boolean - forAccount: string - createdBy: string - createdAt: string - uses: CodeUse[] - [k: string]: unknown -} - -export function isCodeDetail(v: unknown): v is CodeDetail { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.server.getAccountInviteCodes#codeDetail' - ) -} - -export function validateCodeDetail(v: unknown): ValidationResult { - return lexicons.validate( - 'com.atproto.server.getAccountInviteCodes#codeDetail', - v, - ) -} - -export interface CodeUse { - usedBy: string - usedAt: string - [k: string]: unknown -} - -export function isCodeUse(v: unknown): v is CodeUse { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.server.getAccountInviteCodes#codeUse' - ) -} - -export function validateCodeUse(v: unknown): ValidationResult { - return lexicons.validate( - 'com.atproto.server.getAccountInviteCodes#codeUse', - v, - ) -} diff --git a/packages/pds/src/api/com/atproto/admin/getInviteCodes.ts b/packages/pds/src/api/com/atproto/admin/getInviteCodes.ts index 35413c2d5f4..9543b8c4c14 100644 --- a/packages/pds/src/api/com/atproto/admin/getInviteCodes.ts +++ b/packages/pds/src/api/com/atproto/admin/getInviteCodes.ts @@ -35,7 +35,7 @@ export default function (server: Server, ctx: AppContext) { const res = await builder.execute() const codes = res.map((row) => row.code) - const uses = await accntSrvc.getCodeUses(codes) + const uses = await accntSrvc.getInviteCodesUses(codes) const resultCursor = keyset.packFromResult(res) const codeDetails = res.map((row) => ({ diff --git a/packages/pds/src/api/com/atproto/server/getAccountInviteCodes.ts b/packages/pds/src/api/com/atproto/server/getAccountInviteCodes.ts index 0225e98755e..aa210e43a5b 100644 --- a/packages/pds/src/api/com/atproto/server/getAccountInviteCodes.ts +++ b/packages/pds/src/api/com/atproto/server/getAccountInviteCodes.ts @@ -12,25 +12,17 @@ export default function (server: Server, ctx: AppContext) { const accntSrvc = ctx.services.account(ctx.db) - const [user, userCodesRes] = await Promise.all([ + const [user, userCodes] = await Promise.all([ ctx.db.db .selectFrom('user_account') .where('did', '=', requester) .select('createdAt') .executeTakeFirstOrThrow(), - accntSrvc - .selectInviteCodesQb() - .where('forAccount', '=', requester) - .execute(), + accntSrvc.getAccountInviteCodes(requester), ]) - const userCodes = userCodesRes.map((row) => ({ - ...row, - disabled: row.disabled === 1, - })) - const codeUses = await accntSrvc.getCodeUses( - userCodes.map((row) => row.code), + const unusedCodes = userCodes.filter( + (row) => row.available > row.uses.length, ) - const unusedCodes = userCodes.filter((row) => row.available > row.uses) let created: string[] = [] @@ -74,10 +66,7 @@ export default function (server: Server, ctx: AppContext) { const preexisting = includeUsed ? userCodes : unusedCodes const toReturn = [ - ...preexisting.map((row) => ({ - ...row, - uses: codeUses[row.code] ?? [], - })), + ...preexisting, ...created.map((code) => ({ code: code, available: 1, diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 2b52ec1c66d..e4045487326 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -299,6 +299,17 @@ export const schemaDict = { type: 'ref', ref: 'lex:com.atproto.admin.defs#moderation', }, + invitedBy: { + type: 'ref', + ref: 'lex:com.atproto.server.defs#inviteCode', + }, + invites: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.server.defs#inviteCode', + }, + }, }, }, repoViewDetail: { @@ -336,6 +347,17 @@ export const schemaDict = { type: 'ref', ref: 'lex:com.atproto.admin.defs#moderationDetail', }, + invitedBy: { + type: 'ref', + ref: 'lex:com.atproto.server.defs#inviteCode', + }, + invites: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.server.defs#inviteCode', + }, + }, }, }, repoRef: { @@ -600,67 +622,13 @@ export const schemaDict = { type: 'array', items: { type: 'ref', - ref: 'lex:com.atproto.admin.getInviteCodes#codeDetail', + ref: 'lex:com.atproto.server.defs#inviteCode', }, }, }, }, }, }, - codeDetail: { - type: 'object', - required: [ - 'code', - 'available', - 'disabled', - 'forAccount', - 'createdBy', - 'createdAt', - 'uses', - ], - properties: { - code: { - type: 'string', - }, - available: { - type: 'integer', - }, - disabled: { - type: 'boolean', - }, - forAccount: { - type: 'string', - }, - createdBy: { - type: 'string', - }, - createdAt: { - type: 'string', - format: 'datetime', - }, - uses: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:com.atproto.admin.getInviteCodes#codeUse', - }, - }, - }, - }, - codeUse: { - type: 'object', - required: ['usedBy', 'usedAt'], - properties: { - usedBy: { - type: 'string', - format: 'did', - }, - usedAt: { - type: 'string', - format: 'datetime', - }, - }, - }, }, }, ComAtprotoAdminGetModerationAction: { @@ -956,6 +924,9 @@ export const schemaDict = { term: { type: 'string', }, + invitedBy: { + type: 'string', + }, limit: { type: 'integer', minimum: 1, @@ -1908,6 +1879,66 @@ export const schemaDict = { }, }, }, + ComAtprotoServerDefs: { + lexicon: 1, + id: 'com.atproto.server.defs', + defs: { + inviteCode: { + type: 'object', + required: [ + 'code', + 'available', + 'disabled', + 'forAccount', + 'createdBy', + 'createdAt', + 'uses', + ], + properties: { + code: { + type: 'string', + }, + available: { + type: 'integer', + }, + disabled: { + type: 'boolean', + }, + forAccount: { + type: 'string', + }, + createdBy: { + type: 'string', + }, + createdAt: { + type: 'string', + format: 'datetime', + }, + uses: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.server.defs#inviteCodeUse', + }, + }, + }, + }, + inviteCodeUse: { + type: 'object', + required: ['usedBy', 'usedAt'], + properties: { + usedBy: { + type: 'string', + format: 'did', + }, + usedAt: { + type: 'string', + format: 'datetime', + }, + }, + }, + }, + }, ComAtprotoServerDeleteAccount: { lexicon: 1, id: 'com.atproto.server.deleteAccount', @@ -2029,7 +2060,7 @@ export const schemaDict = { type: 'array', items: { type: 'ref', - ref: 'lex:com.atproto.server.getAccountInviteCodes#codeDetail', + ref: 'lex:com.atproto.server.defs#inviteCode', }, }, }, @@ -2041,60 +2072,6 @@ export const schemaDict = { }, ], }, - codeDetail: { - type: 'object', - required: [ - 'code', - 'available', - 'disabled', - 'forAccount', - 'createdBy', - 'createdAt', - 'uses', - ], - properties: { - code: { - type: 'string', - }, - available: { - type: 'integer', - }, - disabled: { - type: 'boolean', - }, - forAccount: { - type: 'string', - }, - createdBy: { - type: 'string', - }, - createdAt: { - type: 'string', - format: 'datetime', - }, - uses: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:com.atproto.server.getAccountInviteCodes#codeUse', - }, - }, - }, - }, - codeUse: { - type: 'object', - required: ['usedBy', 'usedAt'], - properties: { - usedBy: { - type: 'string', - format: 'did', - }, - usedAt: { - type: 'string', - format: 'datetime', - }, - }, - }, }, }, ComAtprotoServerGetSession: { @@ -4423,6 +4400,7 @@ export const ids = { ComAtprotoServerCreateAccount: 'com.atproto.server.createAccount', ComAtprotoServerCreateInviteCode: 'com.atproto.server.createInviteCode', ComAtprotoServerCreateSession: 'com.atproto.server.createSession', + ComAtprotoServerDefs: 'com.atproto.server.defs', ComAtprotoServerDeleteAccount: 'com.atproto.server.deleteAccount', ComAtprotoServerDeleteSession: 'com.atproto.server.deleteSession', ComAtprotoServerDescribeServer: 'com.atproto.server.describeServer', diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts b/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts index a45e65d9200..08e240e0341 100644 --- a/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts +++ b/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts @@ -7,6 +7,7 @@ import { isObj, hasProp } from '../../../../util' import { CID } from 'multiformats/cid' import * as ComAtprotoRepoStrongRef from '../repo/strongRef' import * as ComAtprotoModerationDefs from '../moderation/defs' +import * as ComAtprotoServerDefs from '../server/defs' export interface ActionView { id: number @@ -167,6 +168,8 @@ export interface RepoView { relatedRecords: {}[] indexedAt: string moderation: Moderation + invitedBy?: ComAtprotoServerDefs.InviteCode + invites?: ComAtprotoServerDefs.InviteCode[] [k: string]: unknown } @@ -189,6 +192,8 @@ export interface RepoViewDetail { relatedRecords: {}[] indexedAt: string moderation: ModerationDetail + invitedBy?: ComAtprotoServerDefs.InviteCode + invites?: ComAtprotoServerDefs.InviteCode[] [k: string]: unknown } diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/getInviteCodes.ts b/packages/pds/src/lexicon/types/com/atproto/admin/getInviteCodes.ts index 0723bf96f4c..3e3f97a1ffd 100644 --- a/packages/pds/src/lexicon/types/com/atproto/admin/getInviteCodes.ts +++ b/packages/pds/src/lexicon/types/com/atproto/admin/getInviteCodes.ts @@ -7,6 +7,7 @@ import { lexicons } from '../../../../lexicons' import { isObj, hasProp } from '../../../../util' import { CID } from 'multiformats/cid' import { HandlerAuth } from '@atproto/xrpc-server' +import * as ComAtprotoServerDefs from '../server/defs' export interface QueryParams { sort: 'recent' | 'usage' | (string & {}) @@ -18,7 +19,7 @@ export type InputSchema = undefined export interface OutputSchema { cursor?: string - codes: CodeDetail[] + codes: ComAtprotoServerDefs.InviteCode[] [k: string]: unknown } @@ -42,44 +43,3 @@ export type Handler = (ctx: { req: express.Request res: express.Response }) => Promise | HandlerOutput - -export interface CodeDetail { - code: string - available: number - disabled: boolean - forAccount: string - createdBy: string - createdAt: string - uses: CodeUse[] - [k: string]: unknown -} - -export function isCodeDetail(v: unknown): v is CodeDetail { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.getInviteCodes#codeDetail' - ) -} - -export function validateCodeDetail(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.getInviteCodes#codeDetail', v) -} - -export interface CodeUse { - usedBy: string - usedAt: string - [k: string]: unknown -} - -export function isCodeUse(v: unknown): v is CodeUse { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.getInviteCodes#codeUse' - ) -} - -export function validateCodeUse(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.getInviteCodes#codeUse', v) -} diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/searchRepos.ts b/packages/pds/src/lexicon/types/com/atproto/admin/searchRepos.ts index 74b76070a74..abfda142cbf 100644 --- a/packages/pds/src/lexicon/types/com/atproto/admin/searchRepos.ts +++ b/packages/pds/src/lexicon/types/com/atproto/admin/searchRepos.ts @@ -11,6 +11,7 @@ import * as ComAtprotoAdminDefs from './defs' export interface QueryParams { term?: string + invitedBy?: string limit: number cursor?: string } diff --git a/packages/pds/src/lexicon/types/com/atproto/server/defs.ts b/packages/pds/src/lexicon/types/com/atproto/server/defs.ts new file mode 100644 index 00000000000..9bd67c9d7a5 --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/server/defs.ts @@ -0,0 +1,48 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' + +export interface InviteCode { + code: string + available: number + disabled: boolean + forAccount: string + createdBy: string + createdAt: string + uses: InviteCodeUse[] + [k: string]: unknown +} + +export function isInviteCode(v: unknown): v is InviteCode { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.server.defs#inviteCode' + ) +} + +export function validateInviteCode(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.server.defs#inviteCode', v) +} + +export interface InviteCodeUse { + usedBy: string + usedAt: string + [k: string]: unknown +} + +export function isInviteCodeUse(v: unknown): v is InviteCodeUse { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.server.defs#inviteCodeUse' + ) +} + +export function validateInviteCodeUse(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.server.defs#inviteCodeUse', v) +} diff --git a/packages/pds/src/lexicon/types/com/atproto/server/getAccountInviteCodes.ts b/packages/pds/src/lexicon/types/com/atproto/server/getAccountInviteCodes.ts index 9621431d37d..d5c3e172558 100644 --- a/packages/pds/src/lexicon/types/com/atproto/server/getAccountInviteCodes.ts +++ b/packages/pds/src/lexicon/types/com/atproto/server/getAccountInviteCodes.ts @@ -7,6 +7,7 @@ import { lexicons } from '../../../../lexicons' import { isObj, hasProp } from '../../../../util' import { CID } from 'multiformats/cid' import { HandlerAuth } from '@atproto/xrpc-server' +import * as ComAtprotoServerDefs from './defs' export interface QueryParams { includeUsed: boolean @@ -16,7 +17,7 @@ export interface QueryParams { export type InputSchema = undefined export interface OutputSchema { - codes: CodeDetail[] + codes: ComAtprotoServerDefs.InviteCode[] [k: string]: unknown } @@ -41,50 +42,3 @@ export type Handler = (ctx: { req: express.Request res: express.Response }) => Promise | HandlerOutput - -export interface CodeDetail { - code: string - available: number - disabled: boolean - forAccount: string - createdBy: string - createdAt: string - uses: CodeUse[] - [k: string]: unknown -} - -export function isCodeDetail(v: unknown): v is CodeDetail { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.server.getAccountInviteCodes#codeDetail' - ) -} - -export function validateCodeDetail(v: unknown): ValidationResult { - return lexicons.validate( - 'com.atproto.server.getAccountInviteCodes#codeDetail', - v, - ) -} - -export interface CodeUse { - usedBy: string - usedAt: string - [k: string]: unknown -} - -export function isCodeUse(v: unknown): v is CodeUse { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.server.getAccountInviteCodes#codeUse' - ) -} - -export function validateCodeUse(v: unknown): ValidationResult { - return lexicons.validate( - 'com.atproto.server.getAccountInviteCodes#codeUse', - v, - ) -} diff --git a/packages/pds/src/services/account/index.ts b/packages/pds/src/services/account/index.ts index 994e4129983..723bc387f82 100644 --- a/packages/pds/src/services/account/index.ts +++ b/packages/pds/src/services/account/index.ts @@ -272,7 +272,9 @@ export class AccountService { return this.db.db.selectFrom(builder.as('codes')).selectAll() } - async getCodeUses(codes: string[]): Promise> { + async getInviteCodesUses( + codes: string[], + ): Promise> { const uses: Record = {} if (codes.length > 0) { const usesRes = await this.db.db @@ -288,6 +290,65 @@ export class AccountService { } return uses } + + async getAccountInviteCodes(did: string) { + const res = await this.selectInviteCodesQb() + .where('forAccount', '=', did) + .execute() + const codes = res.map((row) => row.code) + const uses = await this.getInviteCodesUses(codes) + return res.map((row) => ({ + ...row, + uses: uses[row.code] ?? [], + disabled: row.disabled === 1, + })) + } + + async getInviteCodesForAccounts(dids: string[]): Promise { + if (dids.length < 1) return {} + const codeDetailsRes = await this.selectInviteCodesQb() + .where('forAccount', 'in', dids) + .orWhere('code', 'in', (qb) => + qb + .selectFrom('invite_code_use') + .where('usedBy', 'in', dids) + .select('code') + .distinct(), + ) + .execute() + const uses = await this.getInviteCodesUses( + codeDetailsRes.map((row) => row.code), + ) + const codeDetails = codeDetailsRes.map((row) => ({ + ...row, + uses: uses[row.code] ?? [], + disabled: row.disabled === 1, + })) + return codeDetails.reduce((acc, cur) => { + acc[cur.forAccount] ??= { invitedBy: undefined, invites: [] } + acc[cur.forAccount].invites.push(cur) + for (const use of cur.uses) { + acc[use.usedBy] ??= { invitedBy: undefined, invites: [] } + acc[use.usedBy].invitedBy = cur + } + return acc + }, {} as InviteCodesByDid) + } +} + +type InviteCodesByDid = Record< + string, + { invitedBy?: CodeDetail; invites: CodeDetail[] } +> + +type CodeDetail = { + code: string + available: number + disabled: boolean + forAccount: string + createdBy: string + createdAt: string + uses: CodeUse[] } type CodeUse = { diff --git a/packages/pds/src/services/moderation/views.ts b/packages/pds/src/services/moderation/views.ts index f2156f331bb..ccc51351658 100644 --- a/packages/pds/src/services/moderation/views.ts +++ b/packages/pds/src/services/moderation/views.ts @@ -37,7 +37,7 @@ export class ModerationViews { const results = Array.isArray(result) ? result : [result] if (results.length === 0) return [] - const [info, actionResults] = await Promise.all([ + const [info, actionResults, inviteCodes] = await Promise.all([ await this.db.db .selectFrom('did_handle') .leftJoin('user_account', 'user_account.did', 'did_handle.did') @@ -69,6 +69,9 @@ export class ModerationViews { ) .select(['id', 'action', 'subjectDid']) .execute(), + this.services + .account(this.db) + .getInviteCodesForAccounts(results.map((r) => r.did)), ]) const infoByDid = info.reduce( @@ -98,6 +101,8 @@ export class ModerationViews { ? { id: action.id, action: action.action } : undefined, }, + invitedBy: inviteCodes[r.did].invitedBy, + invites: inviteCodes[r.did].invites, } }) From 34fba27e56ce281834d305387874958778addf3d Mon Sep 17 00:00:00 2001 From: dholms Date: Wed, 5 Apr 2023 15:48:07 -0500 Subject: [PATCH 15/15] tests passing --- packages/pds/src/services/moderation/views.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/pds/src/services/moderation/views.ts b/packages/pds/src/services/moderation/views.ts index ccc51351658..b5835caf510 100644 --- a/packages/pds/src/services/moderation/views.ts +++ b/packages/pds/src/services/moderation/views.ts @@ -101,8 +101,8 @@ export class ModerationViews { ? { id: action.id, action: action.action } : undefined, }, - invitedBy: inviteCodes[r.did].invitedBy, - invites: inviteCodes[r.did].invites, + invitedBy: inviteCodes[r.did]?.invitedBy, + invites: inviteCodes[r.did]?.invites, } })