Skip to content

Commit

Permalink
add purchases endpoints on bsky
Browse files Browse the repository at this point in the history
  • Loading branch information
rafaelbsky committed Dec 9, 2024
1 parent 742277a commit a00a74a
Show file tree
Hide file tree
Showing 31 changed files with 1,496 additions and 186 deletions.
50 changes: 50 additions & 0 deletions packages/bsky/src/api/app/bsky/purchase/getFeatures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { Features } from '@atproto/api/dist/client/types/app/bsky/purchase/getFeatures'

export default function (server: Server, ctx: AppContext) {
server.app.bsky.purchase.getFeatures({
auth: ctx.authVerifier.standard,
handler: async ({ auth }) => {
const viewer = auth.credentials.iss

const features = await getFeaturesForViewerEntitlements(viewer, ctx)

return {
encoding: 'application/json',
body: {
features,
},
}
},
})
}

const defaultFeatures: Features = {
customProfileColor: false,
}

const coreEntitlementFeatures: Features = {
customProfileColor: true,
}

const getFeaturesForViewerEntitlements = async (
viewerDid: string,
ctx: AppContext,
): Promise<Features> => {
const { purchaseEntitlements } = await ctx.dataplane.getPurchaseEntitlements({
dids: [viewerDid],
})

if (purchaseEntitlements?.length === 0) {
return defaultFeatures
}

const { entitlements } = purchaseEntitlements[0]

if (entitlements.includes('core')) {
return coreEntitlementFeatures
} else {
return defaultFeatures
}
}
27 changes: 27 additions & 0 deletions packages/bsky/src/api/app/bsky/purchase/getSubscriptionGroup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'

export default function (server: Server, ctx: AppContext) {
server.app.bsky.purchase.getSubscriptionGroup({
handler: async ({ params }) => {
const { group, platform } = params

const { offerings } = await ctx.bsyncClient.getSubscriptionGroup({
group,
platform,
})

return {
encoding: 'application/json',
body: {
group,
offerings: offerings.map(({ id, product }) => ({
id,
platform,
product,
})),
},
}
},
})
}
33 changes: 33 additions & 0 deletions packages/bsky/src/api/app/bsky/purchase/getSubscriptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { Subscription as ProtoSubscription } from '../../../../proto/bsync_pb'
import { Subscription as XrpcSubscription } from '../../../../lexicon/types/app/bsky/purchase/getSubscriptions'

export default function (server: Server, ctx: AppContext) {
server.app.bsky.purchase.getSubscriptions({
auth: ctx.authVerifier.standard,
handler: async ({ auth }) => {
const viewer = auth.credentials.iss

const { email, subscriptions } = await ctx.bsyncClient.getSubscriptions({
actorDid: viewer,
})
return {
encoding: 'application/json',
body: {
email,
subscriptions: subscriptions.map(subscriptionProtoToXrpc),
},
}
},
})
}

const subscriptionProtoToXrpc = (
subscription: ProtoSubscription,
): XrpcSubscription => ({
...subscription,
periodEndsAt: subscription.periodEndsAt?.toDate().toISOString(),
periodStartsAt: subscription.periodStartsAt?.toDate().toISOString(),
purchasedAt: subscription.purchasedAt?.toDate().toISOString(),
})
38 changes: 38 additions & 0 deletions packages/bsky/src/api/app/bsky/purchase/refreshCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { AuthRequiredError } from '@atproto/xrpc-server'
import { RoleOutput, StandardOutput } from '../../../../auth-verifier'

export default function (server: Server, ctx: AppContext) {
server.app.bsky.purchase.refreshCache({
auth: ctx.authVerifier.standardOrRole,
handler: async ({ auth, input }) => {
const { did } = input.body
validateCredentials(did, auth)

await ctx.bsyncClient.addPurchaseOperation({
actorDid: did,
})

return {
encoding: 'application/json',
body: {},
}
},
})
}

const validateCredentials = (
did: string,
auth: StandardOutput | RoleOutput,
) => {
// admins can refresh any user's subscription cache
if (auth.credentials.type === 'role') {
return
}

// users can only refresh their own subscription cache
if (auth.credentials.iss !== did) {
throw new AuthRequiredError('bad issuer')
}
}
8 changes: 8 additions & 0 deletions packages/bsky/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ import listNotifications from './app/bsky/notification/listNotifications'
import updateSeen from './app/bsky/notification/updateSeen'
import putPreferences from './app/bsky/notification/putPreferences'
import registerPush from './app/bsky/notification/registerPush'
import getSubscriptions from './app/bsky/purchase/getSubscriptions'
import getFeatures from './app/bsky/purchase/getFeatures'
import getSubscriptionGroup from './app/bsky/purchase/getSubscriptionGroup'
import refreshCache from './app/bsky/purchase/refreshCache'
import getConfig from './app/bsky/unspecced/getConfig'
import getPopularFeedGenerators from './app/bsky/unspecced/getPopularFeedGenerators'
import getTaggedSuggestions from './app/bsky/unspecced/getTaggedSuggestions'
Expand Down Expand Up @@ -113,6 +117,10 @@ export default function (server: Server, ctx: AppContext) {
updateSeen(server, ctx)
putPreferences(server, ctx)
registerPush(server, ctx)
getSubscriptions(server, ctx)
getFeatures(server, ctx)
getSubscriptionGroup(server, ctx)
refreshCache(server, ctx)
getConfig(server, ctx)
getPopularFeedGenerators(server, ctx)
getTaggedSuggestions(server, ctx)
Expand Down
8 changes: 4 additions & 4 deletions packages/bsky/src/auth-verifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,29 +35,29 @@ export enum RoleStatus {
Missing,
}

type NullOutput = {
export type NullOutput = {
credentials: {
type: 'none'
iss: null
}
}

type StandardOutput = {
export type StandardOutput = {
credentials: {
type: 'standard'
aud: string
iss: string
}
}

type RoleOutput = {
export type RoleOutput = {
credentials: {
type: 'role'
admin: boolean
}
}

type ModServiceOutput = {
export type ModServiceOutput = {
credentials: {
type: 'mod_service'
aud: string
Expand Down
63 changes: 63 additions & 0 deletions packages/bsky/src/data-plane/bsync/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Database } from '../server/db'
import { Service } from '../../proto/bsync_connect'
import { MuteOperation_Type } from '../../proto/bsync_pb'
import { ids } from '../../lexicon/lexicons'
import { Timestamp } from '@bufbuild/protobuf'

export class MockBsync {
constructor(public server: http.Server) {}
Expand Down Expand Up @@ -138,6 +139,68 @@ const createRoutes = (db: Database) => (router: ConnectRouter) =>
throw new Error('not implemented')
},

async addPurchaseOperation(req) {
const { actorDid } = req

// Simulates that a call to the subscription service returns the 'core' entitlement.
const entitlements = ['core']

await db.db
.insertInto('purchase')
.values({
did: actorDid,
entitlements: JSON.stringify(entitlements),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
.onConflict((oc) =>
oc.column('did').doUpdateSet({
entitlements: JSON.stringify(entitlements),
updatedAt: new Date().toISOString(),
}),
)
.execute()
},

async getSubscriptions() {
const TEN_DAYS = 864_000_000
const now = Date.now()
const start = new Date(now - TEN_DAYS)
const end = new Date(now + TEN_DAYS)

// Simulates that a call to the subscription service returns this subscription.
return {
email: 'test@test',
subscriptions: [
{
status: 'active',
renewalStatus: 'will_renew',
group: 'core',
platform: 'web',
offering: 'coreMonthly',
periodEndsAt: Timestamp.fromDate(end),
periodStartsAt: Timestamp.fromDate(start),
purchasedAt: Timestamp.fromDate(start),
},
],
}
},

async getSubscriptionGroup() {
return {
offerings: [
{
id: 'coreMonthly',
product: 'bluesky_plus_core_v1_monthly',
},
{
id: 'coreAnnual',
product: 'bluesky_plus_core_v1_annual',
},
],
}
},

async ping() {
return {}
},
Expand Down
4 changes: 3 additions & 1 deletion packages/bsky/src/data-plane/server/db/database-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import * as blobTakedown from './tables/blob-takedown'
import * as labeler from './tables/labeler'
import * as starterPack from './tables/starter-pack'
import * as quote from './tables/quote'
import * as purchase from './tables/purchase'

export type DatabaseSchemaType = duplicateRecord.PartialDB &
profile.PartialDB &
Expand Down Expand Up @@ -75,7 +76,8 @@ export type DatabaseSchemaType = duplicateRecord.PartialDB &
labeler.PartialDB &
starterPack.PartialDB &
taggedSuggestion.PartialDB &
quote.PartialDB
quote.PartialDB &
purchase.PartialDB

export type DatabaseSchema = Kysely<DatabaseSchemaType>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Kysely } from 'kysely'

export async function up(db: Kysely<unknown>): Promise<void> {
await db.schema
.createTable('purchase')
.addColumn('did', 'varchar', (col) => col.primaryKey())
.addColumn('entitlements', 'jsonb', (col) => col.notNull())
.addColumn('createdAt', 'varchar', (col) => col.notNull())
.addColumn('updatedAt', 'varchar', (col) => col.notNull())
.execute()
}

export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema.dropTable('purchase').execute()
}
1 change: 1 addition & 0 deletions packages/bsky/src/data-plane/server/db/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,4 @@ export * as _20240808T224251220Z from './20240808T224251220Z-post-gate-flags'
export * as _20240829T211238293Z from './20240829T211238293Z-simplify-actor-sync'
export * as _20240831T134810923Z from './20240831T134810923Z-pinned-posts'
export * as _20241114T153108102Z from './20241114T153108102Z-add-starter-packs-name'
export * as _20241206T231908523Z from './20241206T231908523Z-purchase'
13 changes: 13 additions & 0 deletions packages/bsky/src/data-plane/server/db/tables/purchase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ColumnType } from 'kysely'

export const tableName = 'purchase'

export interface Purchase {
did: string
// https://github.com/kysely-org/kysely/issues/137
entitlements: ColumnType<string[], string, string>
createdAt: string
updatedAt: string
}

export type PartialDB = { [tableName]: Purchase }
2 changes: 2 additions & 0 deletions packages/bsky/src/data-plane/server/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import mutes from './mutes'
import notifs from './notifs'
import posts from './posts'
import profile from './profile'
import purchases from './purchases'
import quotes from './quotes'
import records from './records'
import relationships from './relationships'
Expand Down Expand Up @@ -43,6 +44,7 @@ export default (db: Database, idResolver: IdResolver) =>
...notifs(db),
...posts(db),
...profile(db),
...purchases(db),
...quotes(db),
...records(db),
...relationships(db),
Expand Down
33 changes: 33 additions & 0 deletions packages/bsky/src/data-plane/server/routes/purchases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { ServiceImpl } from '@connectrpc/connect'
import { Service } from '../../../proto/bsky_connect'
import { Database } from '../db'
import { keyBy } from '@atproto/common'
import { Timestamp } from '@bufbuild/protobuf'

export default (db: Database): Partial<ServiceImpl<typeof Service>> => ({
async getPurchaseEntitlements(req) {
const { dids } = req

if (dids.length === 0) {
return { purchaseEntitlements: [] }
}

const res = await db.db
.selectFrom('purchase')
.select(['did', 'entitlements', 'createdAt'])
.where('did', 'in', dids ?? [])
.execute()

const byDid = keyBy(res, 'did')
const purchaseEntitlements = res.map((row) => {
const purchase = byDid[row.did] ?? {}

return {
entitlements: purchase.entitlements ?? [],
createdAt: Timestamp.fromDate(new Date(purchase.createdAt)),
}
})

return { purchaseEntitlements }
},
})
Loading

0 comments on commit a00a74a

Please sign in to comment.