Skip to content

Commit 9214427

Browse files
committed
fix: check ticket holds with Object.hasOwn
Review feedback on #63. The ticket sale examples tested holds with `buyer in this.holds`. The `in` operator also matches names that Object.prototype supplies, so a buyer named "constructor" could not hold the free ticket, and expire could add a ticket that no hold had taken. Before the fix, `node examples/ticket-sale.ts hold constructor` printed [{"held":false,"available":1}]. It now prints [{"held":true,"available":0}]. Use Object.hasOwn in the example, both guides, and the README. The example now takes buyer names after `hold`, with ada and grace as the default, and test:package holds a ticket for "constructor" against the packed tarball. Two fixes in docs/agents.md: - The production policy read userId from an unchecked cast, so a null authorization context threw a TypeError instead of a denial. It now narrows the context and returns false for any malformed value. - runDueReminders requires { now }. The verification step now passes a Date and drains the actor role, as the test helper requires. Both snippets type-check against the package.
1 parent f453d3b commit 9214427

6 files changed

Lines changed: 41 additions & 16 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
## 0.17.1 - 2026-10-08
44

5+
- Check holds with `Object.hasOwn` in the README and guide ticket sale
6+
examples. The `in` operator also matched inherited names, so a buyer named
7+
`constructor` could not hold a free ticket, and `expire` could add a ticket
8+
that no hold had taken. `pnpm run test:package` now holds a ticket for that
9+
buyer.
510
- Override `sharp` with 0.35.5 and `source-map-js` with 1.2.2. Two high
611
advisories reach the repository only through the development tooling:
712
`sharp` through Miniflare and `source-map-js` through Vite and PostCSS. The

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ class TicketSale extends Actor {
7676
holds = {}
7777

7878
hold({ buyer }) {
79-
if (this.available === 0 || buyer in this.holds) {
79+
if (this.available === 0 || Object.hasOwn(this.holds, buyer)) {
8080
return { held: false, available: this.available }
8181
}
8282

@@ -90,7 +90,7 @@ class TicketSale extends Actor {
9090
}
9191

9292
expire({ buyer }) {
93-
if (!(buyer in this.holds)) return this.available
93+
if (!Object.hasOwn(this.holds, buyer)) return this.available
9494

9595
const remainingHolds = { ...this.holds }
9696
delete remainingHolds[buyer]

‎docs/agents.md‎

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,6 @@ A production policy must bind the actor type and ID to the authenticated user
105105
or tenant. An actor ID is not a permission:
106106

107107
```typescript
108-
type Subject = { userId: string } | undefined
109-
110108
const ownsCart = ({
111109
actorType,
112110
actorId,
@@ -116,8 +114,10 @@ const ownsCart = ({
116114
actorId: string
117115
authorizationContext: unknown
118116
}) => {
119-
const subject = authorizationContext as Subject
120-
return actorType === "ShoppingCart" && subject !== undefined && actorId === subject.userId
117+
if (actorType !== "ShoppingCart") return false
118+
if (typeof authorizationContext !== "object" || authorizationContext === null) return false
119+
if (!("userId" in authorizationContext)) return false
120+
return typeof authorizationContext.userId === "string" && actorId === authorizationContext.userId
121121
}
122122

123123
const runtime = configure({
@@ -153,7 +153,7 @@ export class TicketSale extends Actor {
153153
holds: Record<string, number> = {}
154154

155155
hold({ buyer }: { buyer: string }): { held: boolean; available: number } {
156-
if (this.available === 0 || buyer in this.holds) {
156+
if (this.available === 0 || Object.hasOwn(this.holds, buyer)) {
157157
return { held: false, available: this.available }
158158
}
159159

@@ -164,7 +164,7 @@ export class TicketSale extends Actor {
164164
}
165165

166166
expire({ buyer }: { buyer: string }): number {
167-
if (!(buyer in this.holds)) return this.available
167+
if (!Object.hasOwn(this.holds, buyer)) return this.available
168168

169169
const remainingHolds = { ...this.holds }
170170
delete remainingHolds[buyer]
@@ -251,8 +251,15 @@ Do these checks before you report that the work is complete:
251251
1. Call `await runtime.doctor.run()`. The report must contain no failed check.
252252
2. Send concurrent calls to one identity with `Promise.all`, and from two
253253
processes if the application runs more than one. Assert the final state.
254-
3. Use `runtime.testing.drain()` and `runtime.testing.runDueReminders()` to
255-
test delayed work without sleeps.
254+
3. Test delayed work without sleeps. `runDueReminders({ now })` enqueues the
255+
reminders that are due at the `Date` you pass. `drain()` then runs the
256+
enqueued actor work:
257+
258+
```typescript
259+
await runtime.testing.runDueReminders({ now: new Date(Date.now() + HOLD_MILLISECONDS) })
260+
await runtime.testing.drain({ roles: ["actors"] })
261+
```
262+
256263
4. Start a process that calls `runtime.run(signal)`, schedule a short
257264
reminder, and stop the process. Start it again after the deadline and
258265
confirm that the reminder ran.

‎docs/virtual-actors.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export class TicketSale extends Actor {
5252
holds: Record<string, number> = {}
5353

5454
hold({ buyer }: { buyer: string }): { held: boolean; available: number } {
55-
if (this.available === 0 || buyer in this.holds) {
55+
if (this.available === 0 || Object.hasOwn(this.holds, buyer)) {
5656
return { held: false, available: this.available }
5757
}
5858

@@ -63,7 +63,7 @@ export class TicketSale extends Actor {
6363
}
6464

6565
expire({ buyer }: { buyer: string }): number {
66-
if (!(buyer in this.holds)) return this.available
66+
if (!Object.hasOwn(this.holds, buyer)) return this.available
6767

6868
const remainingHolds = { ...this.holds }
6969
delete remainingHolds[buyer]
@@ -90,7 +90,8 @@ try {
9090
process.once("SIGTERM", () => controller.abort())
9191
await runtime.run(controller.signal)
9292
} else {
93-
const results = await Promise.all(["ada", "grace"].map((buyer) => sale.hold({ buyer })))
93+
const buyers = process.argv.length > 3 ? process.argv.slice(3) : ["ada", "grace"]
94+
const results = await Promise.all(buyers.map((buyer) => sale.hold({ buyer })))
9495
console.log(JSON.stringify(results))
9596
}
9697
} finally {

‎examples/ticket-sale.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export class TicketSale extends Actor {
1010
holds: Record<string, number> = {}
1111

1212
hold({ buyer }: { buyer: string }): { held: boolean; available: number } {
13-
if (this.available === 0 || buyer in this.holds) {
13+
if (this.available === 0 || Object.hasOwn(this.holds, buyer)) {
1414
return { held: false, available: this.available }
1515
}
1616

@@ -21,7 +21,7 @@ export class TicketSale extends Actor {
2121
}
2222

2323
expire({ buyer }: { buyer: string }): number {
24-
if (!(buyer in this.holds)) return this.available
24+
if (!Object.hasOwn(this.holds, buyer)) return this.available
2525

2626
const remainingHolds = { ...this.holds }
2727
delete remainingHolds[buyer]
@@ -48,7 +48,8 @@ try {
4848
process.once("SIGTERM", () => controller.abort())
4949
await runtime.run(controller.signal)
5050
} else {
51-
const results = await Promise.all(["ada", "grace"].map((buyer) => sale.hold({ buyer })))
51+
const buyers = process.argv.length > 3 ? process.argv.slice(3) : ["ada", "grace"]
52+
const results = await Promise.all(buyers.map((buyer) => sale.hold({ buyer })))
5253
console.log(JSON.stringify(results))
5354
}
5455
} finally {

‎scripts/release-artifact-smoke.mjs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,17 @@ try {
141141
ticketSaleHolds.map((result) => result.available),
142142
[0, 0],
143143
)
144+
const inheritedNameHold = JSON.parse(
145+
await run(process.execPath, [ticketSaleExample, "hold", "constructor"], {
146+
cwd: projectDirectory,
147+
env: { TICKET_DATABASE: join(projectDirectory, "inherited-name.sqlite3") },
148+
}),
149+
)
150+
assert.deepEqual(
151+
inheritedNameHold,
152+
[{ held: true, available: 0 }],
153+
"a buyer named after an Object.prototype property must get the free ticket",
154+
)
144155

145156
const quickstartJson = await run(
146157
join(projectDirectory, "node_modules/.bin/solid-objects"),

0 commit comments

Comments
 (0)