diff --git a/lexicons/com/atproto/admin/defs.json b/lexicons/com/atproto/admin/defs.json index 8bba1c909a..1c02c89ed6 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/disableInviteCodes.json b/lexicons/com/atproto/admin/disableInviteCodes.json new file mode 100644 index 0000000000..bfab5479ac --- /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/admin/getInviteCodes.json b/lexicons/com/atproto/admin/getInviteCodes.json new file mode 100644 index 0000000000..c74a6d09ba --- /dev/null +++ b/lexicons/com/atproto/admin/getInviteCodes.json @@ -0,0 +1,39 @@ +{ + "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": 500, "default": 100}, + "cursor": {"type": "string"} + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["codes"], + "properties": { + "cursor": {"type": "string"}, + "codes": { + "type": "array", + "items": {"type": "ref", "ref": "com.atproto.server.defs#inviteCode"} + } + } + } + } + } + } +} diff --git a/lexicons/com/atproto/admin/searchRepos.json b/lexicons/com/atproto/admin/searchRepos.json index a2750c32f8..8955866ff5 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/createInviteCode.json b/lexicons/com/atproto/server/createInviteCode.json index 44e72bc853..81a967d8a3 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"}, + "forAccount": {"type": "string", "format": "did"} } } }, diff --git a/lexicons/com/atproto/server/defs.json b/lexicons/com/atproto/server/defs.json new file mode 100644 index 0000000000..beb9954bb5 --- /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 new file mode 100644 index 0000000000..ff6e4d6d1c --- /dev/null +++ b/lexicons/com/atproto/server/getAccountInviteCodes.json @@ -0,0 +1,36 @@ +{ + "lexicon": 1, + "id": "com.atproto.server.getAccountInviteCodes", + "defs": { + "main": { + "type": "query", + "description": "Get all invite codes for a given account", + "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": "com.atproto.server.defs#inviteCode" + } + } + } + } + }, + "errors": [ + {"name": "DuplicateCreate"} + ] + } + } +} diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index ece4054803..2d3905448e 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -8,6 +8,8 @@ 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 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' @@ -34,9 +36,11 @@ 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' +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' @@ -86,6 +90,8 @@ 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 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' @@ -112,9 +118,11 @@ 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' +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' @@ -240,6 +248,28 @@ 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) + }) + } + + 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, @@ -569,6 +599,17 @@ export class ServerNS { }) } + getAccountInviteCodes( + params?: ComAtprotoServerGetAccountInviteCodes.QueryParams, + opts?: ComAtprotoServerGetAccountInviteCodes.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.server.getAccountInviteCodes', params, undefined, opts) + .catch((e) => { + throw ComAtprotoServerGetAccountInviteCodes.toKnownErr(e) + }) + } + getSession( params?: ComAtprotoServerGetSession.QueryParams, opts?: ComAtprotoServerGetSession.CallOptions, diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 9a277fb1dc..e404548732 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: { @@ -530,6 +552,85 @@ 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', + }, + }, + }, + }, + }, + }, + }, + }, + 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: 500, + default: 100, + }, + cursor: { + type: 'string', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['codes'], + properties: { + cursor: { + type: 'string', + }, + codes: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.server.defs#inviteCode', + }, + }, + }, + }, + }, + }, + }, + }, ComAtprotoAdminGetModerationAction: { lexicon: 1, id: 'com.atproto.admin.getModerationAction', @@ -823,6 +924,9 @@ export const schemaDict = { term: { type: 'string', }, + invitedBy: { + type: 'string', + }, limit: { type: 'integer', minimum: 1, @@ -1698,6 +1802,10 @@ export const schemaDict = { useCount: { type: 'integer', }, + forAccount: { + type: 'string', + format: 'did', + }, }, }, }, @@ -1771,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', @@ -1862,6 +2030,50 @@ export const schemaDict = { }, }, }, + ComAtprotoServerGetAccountInviteCodes: { + lexicon: 1, + id: 'com.atproto.server.getAccountInviteCodes', + defs: { + main: { + type: 'query', + description: 'Get all invite codes for a given account', + 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.defs#inviteCode', + }, + }, + }, + }, + }, + errors: [ + { + name: 'DuplicateCreate', + }, + ], + }, + }, + }, ComAtprotoServerGetSession: { lexicon: 1, id: 'com.atproto.server.getSession', @@ -4158,6 +4370,8 @@ 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', + ComAtprotoAdminGetInviteCodes: 'com.atproto.admin.getInviteCodes', ComAtprotoAdminGetModerationAction: 'com.atproto.admin.getModerationAction', ComAtprotoAdminGetModerationActions: 'com.atproto.admin.getModerationActions', ComAtprotoAdminGetModerationReport: 'com.atproto.admin.getModerationReport', @@ -4186,9 +4400,12 @@ 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', + ComAtprotoServerGetAccountInviteCodes: + 'com.atproto.server.getAccountInviteCodes', ComAtprotoServerGetSession: 'com.atproto.server.getSession', ComAtprotoServerRefreshSession: 'com.atproto.server.refreshSession', ComAtprotoServerRequestAccountDelete: 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 e96de2bd41..8d6cc65c27 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/disableInviteCodes.ts b/packages/api/src/client/types/com/atproto/admin/disableInviteCodes.ts new file mode 100644 index 0000000000..7ceed97f91 --- /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/admin/getInviteCodes.ts b/packages/api/src/client/types/com/atproto/admin/getInviteCodes.ts new file mode 100644 index 0000000000..eb5604839d --- /dev/null +++ b/packages/api/src/client/types/com/atproto/admin/getInviteCodes.ts @@ -0,0 +1,39 @@ +/** + * 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' +import * as ComAtprotoServerDefs from '../server/defs' + +export interface QueryParams { + sort?: 'recent' | 'usage' | (string & {}) + limit?: number + cursor?: string +} + +export type InputSchema = undefined + +export interface OutputSchema { + cursor?: string + codes: ComAtprotoServerDefs.InviteCode[] + [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 +} 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 10f24584f1..a43e0ee732 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/createInviteCode.ts b/packages/api/src/client/types/com/atproto/server/createInviteCode.ts index c273b17ec2..a1ce922dd7 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 + forAccount?: string [k: string]: unknown } 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 0000000000..e6be379890 --- /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 new file mode 100644 index 0000000000..451d7800f2 --- /dev/null +++ b/packages/api/src/client/types/com/atproto/server/getAccountInviteCodes.ts @@ -0,0 +1,44 @@ +/** + * 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' +import * as ComAtprotoServerDefs from './defs' + +export interface QueryParams { + includeUsed?: boolean + createAvailable?: boolean +} + +export type InputSchema = undefined + +export interface OutputSchema { + codes: ComAtprotoServerDefs.InviteCode[] + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers +} + +export interface Response { + success: boolean + headers: Headers + 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/dev-env/src/index.ts b/packages/dev-env/src/index.ts index 7a410afb9b..7c5706c63e 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', 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 0000000000..fb7e387bba --- /dev/null +++ b/packages/pds/src/api/com/atproto/admin/disableInviteCodes.ts @@ -0,0 +1,29 @@ +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') + .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/getInviteCodes.ts b/packages/pds/src/api/com/atproto/admin/getInviteCodes.ts new file mode 100644 index 0000000000..9543b8c4c1 --- /dev/null +++ b/packages/pds/src/api/com/atproto/admin/getInviteCodes.ts @@ -0,0 +1,104 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { InvalidRequestError } from '@atproto/xrpc-server' +import { + LabeledResult, + Cursor, + GenericKeyset, + paginate, +} from '../../../../db/pagination' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.admin.getInviteCodes({ + auth: ctx.adminVerifier, + handler: async ({ params }) => { + const { sort, limit, cursor } = params + 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}`) + } + + const accntSrvc = ctx.services.account(ctx.db) + + let builder = accntSrvc.selectInviteCodesQb() + builder = paginate(builder, { + limit, + cursor, + keyset, + }) + + const res = await builder.execute() + + const codes = res.map((row) => row.code) + const uses = await accntSrvc.getInviteCodesUses(codes) + + const resultCursor = keyset.packFromResult(res) + const codeDetails = 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: labeled.primary.toString(), + secondary: labeled.secondary, + } + } + cursorToLabeledResult(cursor: Cursor) { + const primaryCode = parseInt(cursor.primary, 10) + if (isNaN(primaryCode)) { + throw new InvalidRequestError('Malformed cursor') + } + return { + primary: primaryCode, + 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 35e2670e3c..9991c5f7b8 100644 --- a/packages/pds/src/api/com/atproto/admin/index.ts +++ b/packages/pds/src/api/com/atproto/admin/index.ts @@ -10,6 +10,8 @@ import getModerationAction from './getModerationAction' import getModerationActions from './getModerationActions' import getModerationReport from './getModerationReport' import getModerationReports from './getModerationReports' +import disableInviteCodes from './disableInviteCodes' +import getInviteCodes from './getInviteCodes' export default function (server: Server, ctx: AppContext) { resolveModerationReports(server, ctx) @@ -22,4 +24,6 @@ export default function (server: Server, ctx: AppContext) { getModerationActions(server, ctx) getModerationReport(server, ctx) getModerationReports(server, ctx) + disableInviteCodes(server, ctx) + getInviteCodes(server, ctx) } diff --git a/packages/pds/src/api/com/atproto/server/createInviteCode.ts b/packages/pds/src/api/com/atproto/server/createInviteCode.ts index 6c4445c9b3..cde04c12aa 100644 --- a/packages/pds/src/api/com/atproto/server/createInviteCode.ts +++ b/packages/pds/src/api/com/atproto/server/createInviteCode.ts @@ -1,22 +1,14 @@ -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({ auth: ctx.adminVerifier, handler: async ({ input, req }) => { - const { useCount } = input.body + const { useCount, forAccount = 'admin' } = 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') @@ -24,7 +16,7 @@ export default function (server: Server, ctx: AppContext) { code: code, availableUses: useCount, disabled: 0, - forUser: 'admin', + 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 new file mode 100644 index 0000000000..aa210e43a5 --- /dev/null +++ b/packages/pds/src/api/com/atproto/server/getAccountInviteCodes.ts @@ -0,0 +1,89 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { genInvCodes } from './util' +import { InvalidRequestError } from '@atproto/xrpc-server' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.server.getAccountInviteCodes({ + auth: ctx.accessVerifierCheckTakedown, + handler: async ({ params, auth }) => { + const requester = auth.credentials.did + const { includeUsed, createAvailable } = params + + const accntSrvc = ctx.services.account(ctx.db) + + const [user, userCodes] = await Promise.all([ + ctx.db.db + .selectFrom('user_account') + .where('did', '=', requester) + .select('createdAt') + .executeTakeFirstOrThrow(), + accntSrvc.getAccountInviteCodes(requester), + ]) + const unusedCodes = userCodes.filter( + (row) => row.available > row.uses.length, + ) + + 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 + const now = new Date().toISOString() + 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: now, + })) + 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', + ) + } + }) + } + } + + const preexisting = includeUsed ? userCodes : unusedCodes + + const toReturn = [ + ...preexisting, + ...created.map((code) => ({ + code: code, + available: 1, + disabled: false, + forAccount: requester, + createdBy: requester, + createdAt: now, + uses: [], + })), + ] + + 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 ed084ebb15..5de19a1f40 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 getAccountInviteCodes from './getAccountInviteCodes' 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) + getAccountInviteCodes(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 0000000000..30087b5bfc --- /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 2ac0a3b0f8..b72564bc98 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/db/util.ts b/packages/pds/src/db/util.ts index f1c8b40fc9..eb3640a731 100644 --- a/packages/pds/src/db/util.ts +++ b/packages/pds/src/db/util.ts @@ -27,6 +27,10 @@ export const softDeleted = (repoOrRecord: { takedownId: number | null }) => { export const countAll = sql`count(*)` +export const nullToZero = (ref: DbRef) => { + return sql`coalesce(${ref}, 0)` +} + export const dummyDialect = { createAdapter() { return new SqliteAdapter() diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index 589086ef58..d99d789c2f 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -9,6 +9,8 @@ import { StreamAuthVerifier, } from '@atproto/xrpc-server' import { schemas } from './lexicons' +import * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes' +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' @@ -36,6 +38,7 @@ 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 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' @@ -135,6 +138,23 @@ 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) + } + + getInviteCodes( + cfg: ConfigOf>>, + ) { + const nsid = 'com.atproto.admin.getInviteCodes' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + getModerationAction( cfg: ConfigOf< AV, @@ -383,6 +403,16 @@ export class ServerNS { return this._server.xrpc.method(nsid, cfg) } + getAccountInviteCodes( + cfg: ConfigOf< + AV, + ComAtprotoServerGetAccountInviteCodes.Handler> + >, + ) { + const nsid = 'com.atproto.server.getAccountInviteCodes' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + getSession( cfg: ConfigOf>>, ) { diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 9a277fb1dc..e404548732 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: { @@ -530,6 +552,85 @@ 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', + }, + }, + }, + }, + }, + }, + }, + }, + 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: 500, + default: 100, + }, + cursor: { + type: 'string', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['codes'], + properties: { + cursor: { + type: 'string', + }, + codes: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.server.defs#inviteCode', + }, + }, + }, + }, + }, + }, + }, + }, ComAtprotoAdminGetModerationAction: { lexicon: 1, id: 'com.atproto.admin.getModerationAction', @@ -823,6 +924,9 @@ export const schemaDict = { term: { type: 'string', }, + invitedBy: { + type: 'string', + }, limit: { type: 'integer', minimum: 1, @@ -1698,6 +1802,10 @@ export const schemaDict = { useCount: { type: 'integer', }, + forAccount: { + type: 'string', + format: 'did', + }, }, }, }, @@ -1771,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', @@ -1862,6 +2030,50 @@ export const schemaDict = { }, }, }, + ComAtprotoServerGetAccountInviteCodes: { + lexicon: 1, + id: 'com.atproto.server.getAccountInviteCodes', + defs: { + main: { + type: 'query', + description: 'Get all invite codes for a given account', + 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.defs#inviteCode', + }, + }, + }, + }, + }, + errors: [ + { + name: 'DuplicateCreate', + }, + ], + }, + }, + }, ComAtprotoServerGetSession: { lexicon: 1, id: 'com.atproto.server.getSession', @@ -4158,6 +4370,8 @@ 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', + ComAtprotoAdminGetInviteCodes: 'com.atproto.admin.getInviteCodes', ComAtprotoAdminGetModerationAction: 'com.atproto.admin.getModerationAction', ComAtprotoAdminGetModerationActions: 'com.atproto.admin.getModerationActions', ComAtprotoAdminGetModerationReport: 'com.atproto.admin.getModerationReport', @@ -4186,9 +4400,12 @@ 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', + ComAtprotoServerGetAccountInviteCodes: + 'com.atproto.server.getAccountInviteCodes', ComAtprotoServerGetSession: 'com.atproto.server.getSession', ComAtprotoServerRefreshSession: 'com.atproto.server.refreshSession', ComAtprotoServerRequestAccountDelete: 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 a45e65d920..08e240e034 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/disableInviteCodes.ts b/packages/pds/src/lexicon/types/com/atproto/admin/disableInviteCodes.ts new file mode 100644 index 0000000000..2e9d326afe --- /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/admin/getInviteCodes.ts b/packages/pds/src/lexicon/types/com/atproto/admin/getInviteCodes.ts new file mode 100644 index 0000000000..3e3f97a1ff --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/admin/getInviteCodes.ts @@ -0,0 +1,45 @@ +/** + * 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' +import * as ComAtprotoServerDefs from '../server/defs' + +export interface QueryParams { + sort: 'recent' | 'usage' | (string & {}) + limit: number + cursor?: string +} + +export type InputSchema = undefined + +export interface OutputSchema { + cursor?: string + codes: ComAtprotoServerDefs.InviteCode[] + [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 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 74b76070a7..abfda142cb 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/createInviteCode.ts b/packages/pds/src/lexicon/types/com/atproto/server/createInviteCode.ts index b5fac25bfc..f95b098167 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 + forAccount?: string [k: string]: unknown } 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 0000000000..9bd67c9d7a --- /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 new file mode 100644 index 0000000000..d5c3e17255 --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/server/getAccountInviteCodes.ts @@ -0,0 +1,44 @@ +/** + * 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' +import * as ComAtprotoServerDefs from './defs' + +export interface QueryParams { + includeUsed: boolean + createAvailable: boolean +} + +export type InputSchema = undefined + +export interface OutputSchema { + codes: ComAtprotoServerDefs.InviteCode[] + [k: string]: unknown +} + +export type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema +} + +export interface HandlerError { + status: number + message?: string + error?: 'DuplicateCreate' +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type Handler = (ctx: { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +}) => Promise | HandlerOutput diff --git a/packages/pds/src/services/account/index.ts b/packages/pds/src/services/account/index.ts index edba837729..723bc387f8 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,112 @@ 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 getInviteCodesUses( + 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 + } + + 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 = { + usedBy: string + usedAt: string } export class UserAlreadyExistsError extends Error {} diff --git a/packages/pds/src/services/moderation/views.ts b/packages/pds/src/services/moderation/views.ts index f2156f331b..b5835caf51 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, } }) diff --git a/packages/pds/tests/_util.ts b/packages/pds/tests/_util.ts index 1f300086a6..6446701922 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 b7953cab15..8767623946 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' @@ -19,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', @@ -45,6 +47,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 +461,94 @@ 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.getAccountInviteCodes() + 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: 'did:example:test', + usedAt: new Date().toISOString(), + })), + ) + .execute() + + 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.getAccountInviteCodes() + expect(res3.data.codes.length).toBe(7) + const res4 = await agent.api.com.atproto.server.getAccountInviteCodes({ + includeUsed: false, + }) + 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 0000000000..deef22b1c8 --- /dev/null +++ b/packages/pds/tests/views/admin/invites.test.ts @@ -0,0 +1,155 @@ +import AtpAgent from '@atproto/api' +import { runTestServer, CloseFn, adminAuth } from '../../_util' +import { randomStr } from '@atproto/crypto' + +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) + }) +})