@porulle/plugin-pos 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/hooks/checkout-pos.d.ts.map +1 -1
- package/dist/hooks/checkout-pos.js +9 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/routes/auth.d.ts +1 -1
- package/dist/routes/auth.d.ts.map +1 -1
- package/dist/routes/auth.js +10 -4
- package/dist/routes/returns.d.ts.map +1 -1
- package/dist/routes/returns.js +25 -4
- package/dist/schema.d.ts +109 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +11 -0
- package/dist/services/exchange-service.d.ts.map +1 -1
- package/dist/services/exchange-service.js +34 -11
- package/dist/services/pin-service.d.ts +11 -0
- package/dist/services/pin-service.d.ts.map +1 -1
- package/dist/services/pin-service.js +122 -24
- package/dist/services/transaction-service.d.ts.map +1 -1
- package/dist/services/transaction-service.js +5 -5
- package/dist/types.d.ts +7 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/hooks/checkout-pos.ts +9 -2
- package/src/index.ts +3 -0
- package/src/routes/auth.ts +10 -5
- package/src/routes/returns.ts +47 -5
- package/src/schema.ts +13 -0
- package/src/services/exchange-service.ts +56 -11
- package/src/services/pin-service.ts +147 -25
- package/src/services/transaction-service.ts +5 -4
- package/src/types.ts +8 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { eq, and } from "@porulle/core/drizzle";
|
|
2
2
|
import { Ok, Err } from "@porulle/core";
|
|
3
3
|
import type { PluginResult } from "@porulle/core";
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
4
|
+
import { user } from "@porulle/core/auth-schema";
|
|
5
|
+
import { posOperatorPins, posPinAttempts, posShifts } from "../schema.js";
|
|
6
|
+
import type { Db, OperatorPin, PinAttempt, Shift } from "../types.js";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* PIN auth runtime (issue #51).
|
|
@@ -16,6 +17,14 @@ import type { Db, OperatorPin, Shift } from "../types.js";
|
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
19
|
const PBKDF2_ITERATIONS = 100_000;
|
|
20
|
+
const DEFAULT_LOCKOUT_MAX_ATTEMPTS = 5;
|
|
21
|
+
const DEFAULT_LOCKOUT_WINDOW_MINUTES = 15;
|
|
22
|
+
export const PIN_LOCKOUT_ERROR = "Too many failed PIN attempts; try again later";
|
|
23
|
+
|
|
24
|
+
type PinVerifyOutcome =
|
|
25
|
+
| { status: "ok"; record: OperatorPin }
|
|
26
|
+
| { status: "locked" }
|
|
27
|
+
| { status: "invalid" };
|
|
19
28
|
|
|
20
29
|
/** Narrow Better Auth surface the PIN runtime uses. */
|
|
21
30
|
export interface PinAuthApi {
|
|
@@ -70,6 +79,23 @@ export async function verifyPinHash(pin: string, encoded: string): Promise<boole
|
|
|
70
79
|
return diff === 0;
|
|
71
80
|
}
|
|
72
81
|
|
|
82
|
+
async function ensureOperatorUser(db: Db, operatorId: string): Promise<void> {
|
|
83
|
+
// SEC-16/R-01: ensure ONLY a user row exists so a user-referenced API key can
|
|
84
|
+
// be minted. We create NO org membership and grant NO role — better-auth's
|
|
85
|
+
// org-referenced keys require owner/admin (a cashier->owner escalation), so
|
|
86
|
+
// the key is user-referenced and carries the operator's store in metadata,
|
|
87
|
+
// read back by authMiddleware.
|
|
88
|
+
const users = await db.select().from(user).where(eq(user.id, operatorId));
|
|
89
|
+
if (users.length === 0) {
|
|
90
|
+
await db.insert(user).values({
|
|
91
|
+
id: operatorId,
|
|
92
|
+
name: operatorId,
|
|
93
|
+
email: `${operatorId}@pos.local`,
|
|
94
|
+
emailVerified: true,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
73
99
|
export class PinService {
|
|
74
100
|
constructor(
|
|
75
101
|
private db: Db,
|
|
@@ -78,9 +104,80 @@ export class PinService {
|
|
|
78
104
|
apiKeyScope?: string | undefined;
|
|
79
105
|
/** Shift-credential lifetime in seconds. Default: 12h. */
|
|
80
106
|
credentialTtlSeconds?: number | undefined;
|
|
107
|
+
/** Failed PIN attempts before lockout (SEC-15). Default: 5. */
|
|
108
|
+
lockoutMaxAttempts?: number | undefined;
|
|
109
|
+
/** Failure window and lockout duration in minutes (SEC-15). Default: 15. */
|
|
110
|
+
lockoutWindowMinutes?: number | undefined;
|
|
81
111
|
} = {},
|
|
82
112
|
) {}
|
|
83
113
|
|
|
114
|
+
private lockoutMaxAttempts(): number {
|
|
115
|
+
return this.options.lockoutMaxAttempts ?? DEFAULT_LOCKOUT_MAX_ATTEMPTS;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private lockoutWindowMs(): number {
|
|
119
|
+
const minutes = this.options.lockoutWindowMinutes ?? DEFAULT_LOCKOUT_WINDOW_MINUTES;
|
|
120
|
+
return minutes * 60 * 1000;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private async getPinAttempt(
|
|
124
|
+
orgId: string,
|
|
125
|
+
operatorId: string,
|
|
126
|
+
): Promise<PinAttempt | undefined> {
|
|
127
|
+
const rows = await this.db
|
|
128
|
+
.select()
|
|
129
|
+
.from(posPinAttempts)
|
|
130
|
+
.where(and(
|
|
131
|
+
eq(posPinAttempts.organizationId, orgId),
|
|
132
|
+
eq(posPinAttempts.operatorId, operatorId),
|
|
133
|
+
));
|
|
134
|
+
return rows[0] as PinAttempt | undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private isLockedOut(attempt: PinAttempt | undefined, now: Date): boolean {
|
|
138
|
+
return attempt?.lockedUntil != null && attempt.lockedUntil > now;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async recordFailedPinAttempt(orgId: string, operatorId: string): Promise<void> {
|
|
142
|
+
const now = new Date();
|
|
143
|
+
const windowMs = this.lockoutWindowMs();
|
|
144
|
+
const maxAttempts = this.lockoutMaxAttempts();
|
|
145
|
+
const existing = await this.getPinAttempt(orgId, operatorId);
|
|
146
|
+
|
|
147
|
+
let failedCount = 1;
|
|
148
|
+
if (existing) {
|
|
149
|
+
const windowExpired = now.getTime() - existing.updatedAt.getTime() > windowMs;
|
|
150
|
+
failedCount = windowExpired ? 1 : existing.failedCount + 1;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const lockedUntil = failedCount >= maxAttempts
|
|
154
|
+
? new Date(now.getTime() + windowMs)
|
|
155
|
+
: null;
|
|
156
|
+
|
|
157
|
+
if (existing) {
|
|
158
|
+
await this.db
|
|
159
|
+
.update(posPinAttempts)
|
|
160
|
+
.set({ failedCount, lockedUntil, updatedAt: now })
|
|
161
|
+
.where(eq(posPinAttempts.id, existing.id));
|
|
162
|
+
} else {
|
|
163
|
+
await this.db.insert(posPinAttempts).values({
|
|
164
|
+
organizationId: orgId,
|
|
165
|
+
operatorId,
|
|
166
|
+
failedCount,
|
|
167
|
+
lockedUntil,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private async clearPinAttempts(orgId: string, operatorId: string): Promise<void> {
|
|
173
|
+
const existing = await this.getPinAttempt(orgId, operatorId);
|
|
174
|
+
if (!existing) return;
|
|
175
|
+
await this.db
|
|
176
|
+
.update(posPinAttempts)
|
|
177
|
+
.set({ failedCount: 0, lockedUntil: null, updatedAt: new Date() })
|
|
178
|
+
.where(eq(posPinAttempts.id, existing.id));
|
|
179
|
+
}
|
|
180
|
+
|
|
84
181
|
async setPin(
|
|
85
182
|
orgId: string,
|
|
86
183
|
input: { operatorId: string; pin: string; canOverride?: boolean | undefined },
|
|
@@ -120,7 +217,11 @@ export class PinService {
|
|
|
120
217
|
orgId: string,
|
|
121
218
|
operatorId: string,
|
|
122
219
|
pin: string,
|
|
123
|
-
): Promise<
|
|
220
|
+
): Promise<PinVerifyOutcome> {
|
|
221
|
+
const now = new Date();
|
|
222
|
+
const attempt = await this.getPinAttempt(orgId, operatorId);
|
|
223
|
+
if (this.isLockedOut(attempt, now)) return { status: "locked" };
|
|
224
|
+
|
|
124
225
|
const rows = await this.db
|
|
125
226
|
.select()
|
|
126
227
|
.from(posOperatorPins)
|
|
@@ -129,8 +230,18 @@ export class PinService {
|
|
|
129
230
|
eq(posOperatorPins.operatorId, operatorId),
|
|
130
231
|
));
|
|
131
232
|
const record = rows[0] as OperatorPin | undefined;
|
|
132
|
-
if (!record)
|
|
133
|
-
|
|
233
|
+
if (!record) {
|
|
234
|
+
await this.recordFailedPinAttempt(orgId, operatorId);
|
|
235
|
+
return { status: "invalid" };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!(await verifyPinHash(pin, record.pinHash))) {
|
|
239
|
+
await this.recordFailedPinAttempt(orgId, operatorId);
|
|
240
|
+
return { status: "invalid" };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
await this.clearPinAttempts(orgId, operatorId);
|
|
244
|
+
return { status: "ok", record };
|
|
134
245
|
}
|
|
135
246
|
|
|
136
247
|
/**
|
|
@@ -149,8 +260,9 @@ export class PinService {
|
|
|
149
260
|
apiKey: string;
|
|
150
261
|
expiresAt: string;
|
|
151
262
|
}>> {
|
|
152
|
-
const
|
|
153
|
-
if (
|
|
263
|
+
const verified = await this.verifyOperatorPin(orgId, input.operatorId, input.pin);
|
|
264
|
+
if (verified.status === "locked") return Err(PIN_LOCKOUT_ERROR);
|
|
265
|
+
if (verified.status !== "ok") return Err("Invalid operator or PIN");
|
|
154
266
|
|
|
155
267
|
const conditions = [
|
|
156
268
|
eq(posShifts.organizationId, orgId),
|
|
@@ -166,22 +278,31 @@ export class PinService {
|
|
|
166
278
|
return Err("PIN login requires the Better Auth instance (plugin ctx.auth) — are routes mounted by createServer?");
|
|
167
279
|
}
|
|
168
280
|
|
|
281
|
+
await ensureOperatorUser(this.db, input.operatorId);
|
|
282
|
+
|
|
169
283
|
const ttlSeconds = this.options.credentialTtlSeconds ?? 12 * 3600;
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
284
|
+
let created: { key?: string; id?: string };
|
|
285
|
+
try {
|
|
286
|
+
created = (await auth.api.createApiKey({
|
|
287
|
+
body: {
|
|
288
|
+
// Better Auth caps name length at 32; the full shift id lives on the
|
|
289
|
+
// shift row (metadata.pinLoginApiKeyId links back).
|
|
290
|
+
name: `pos-shift-${shift.id.slice(0, 8)}`,
|
|
291
|
+
userId: input.operatorId,
|
|
292
|
+
organizationId: orgId,
|
|
293
|
+
expiresIn: ttlSeconds,
|
|
294
|
+
permissions: { pos: ["operate"] },
|
|
295
|
+
metadata: { operatorId: input.operatorId, organizationId: orgId },
|
|
296
|
+
// Shift/terminal binding is recorded on the shift row; the key name
|
|
297
|
+
// carries the shift id for traceability.
|
|
298
|
+
// The plugin registers this scope via its apiKeyScopes manifest.
|
|
299
|
+
configId: this.options.apiKeyScope ?? "pos",
|
|
300
|
+
},
|
|
301
|
+
})) as { key?: string; id?: string };
|
|
302
|
+
} catch (error) {
|
|
303
|
+
const message = error instanceof Error ? error.message : "Failed to mint shift credential";
|
|
304
|
+
return Err(message);
|
|
305
|
+
}
|
|
185
306
|
if (!created?.key) return Err("Failed to mint shift credential");
|
|
186
307
|
|
|
187
308
|
// Record the credential on the shift so admins can trace/revoke it.
|
|
@@ -215,9 +336,10 @@ export class PinService {
|
|
|
215
336
|
action: string;
|
|
216
337
|
approvedAt: string;
|
|
217
338
|
}>> {
|
|
218
|
-
const
|
|
219
|
-
if (
|
|
220
|
-
if (
|
|
339
|
+
const verified = await this.verifyOperatorPin(orgId, input.operatorId, input.pin);
|
|
340
|
+
if (verified.status === "locked") return Err(PIN_LOCKOUT_ERROR);
|
|
341
|
+
if (verified.status !== "ok") return Err("Invalid operator or PIN");
|
|
342
|
+
if (!verified.record.canOverride) return Err("This operator cannot approve overrides");
|
|
221
343
|
return Ok({
|
|
222
344
|
approved: true,
|
|
223
345
|
operatorId: input.operatorId,
|
|
@@ -25,7 +25,7 @@ export class TransactionService {
|
|
|
25
25
|
.where(and(eq(posShifts.id, input.shiftId), eq(posShifts.status, "open")));
|
|
26
26
|
if (shifts.length === 0) return Err("Shift is not open");
|
|
27
27
|
|
|
28
|
-
const receiptNumber = await this.generateReceiptNumber(input.terminalId);
|
|
28
|
+
const receiptNumber = await this.generateReceiptNumber(orgId, input.terminalId);
|
|
29
29
|
|
|
30
30
|
const rows = await this.db
|
|
31
31
|
.insert(posTransactions)
|
|
@@ -121,7 +121,7 @@ export class TransactionService {
|
|
|
121
121
|
await tx
|
|
122
122
|
.update(posShifts)
|
|
123
123
|
.set({ voidsCount: sql`${posShifts.voidsCount} + 1`, updatedAt: new Date() })
|
|
124
|
-
.where(eq(posShifts.id, txn.shiftId));
|
|
124
|
+
.where(and(eq(posShifts.id, txn.shiftId), eq(posShifts.organizationId, orgId)));
|
|
125
125
|
|
|
126
126
|
return Ok(updated[0]!);
|
|
127
127
|
});
|
|
@@ -207,7 +207,7 @@ export class TransactionService {
|
|
|
207
207
|
salesTotal: sql`${posShifts.salesTotal} + ${paymentTotal}`,
|
|
208
208
|
updatedAt: new Date(),
|
|
209
209
|
})
|
|
210
|
-
.where(eq(posShifts.id, txn.shiftId));
|
|
210
|
+
.where(and(eq(posShifts.id, txn.shiftId), eq(posShifts.organizationId, txn.organizationId)));
|
|
211
211
|
}
|
|
212
212
|
|
|
213
213
|
return Ok(txn);
|
|
@@ -219,7 +219,7 @@ export class TransactionService {
|
|
|
219
219
|
// ─── Receipt Number Generation ─────────────────────────────────────
|
|
220
220
|
// Sequential per terminal per day: {terminal_code}-{sequence}
|
|
221
221
|
|
|
222
|
-
private async generateReceiptNumber(terminalId: string): Promise<string> {
|
|
222
|
+
private async generateReceiptNumber(orgId: string, terminalId: string): Promise<string> {
|
|
223
223
|
const today = new Date();
|
|
224
224
|
const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
|
225
225
|
const startOfDayISO = startOfDay.toISOString();
|
|
@@ -238,6 +238,7 @@ export class TransactionService {
|
|
|
238
238
|
.select({ count: sql<number>`COUNT(*)`.as("count") })
|
|
239
239
|
.from(posTransactions)
|
|
240
240
|
.where(and(
|
|
241
|
+
eq(posTransactions.organizationId, orgId),
|
|
241
242
|
eq(posTransactions.terminalId, terminalId),
|
|
242
243
|
sql`${posTransactions.createdAt} >= ${startOfDayISO}::timestamptz`,
|
|
243
244
|
));
|
package/src/types.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
posPayments,
|
|
8
8
|
posReturnItems,
|
|
9
9
|
posOperatorPins,
|
|
10
|
+
posPinAttempts,
|
|
10
11
|
} from "./schema.js";
|
|
11
12
|
|
|
12
13
|
export type Terminal = typeof posTerminals.$inferSelect;
|
|
@@ -30,6 +31,9 @@ export type ReturnItemInsert = typeof posReturnItems.$inferInsert;
|
|
|
30
31
|
export type OperatorPin = typeof posOperatorPins.$inferSelect;
|
|
31
32
|
export type OperatorPinInsert = typeof posOperatorPins.$inferInsert;
|
|
32
33
|
|
|
34
|
+
export type PinAttempt = typeof posPinAttempts.$inferSelect;
|
|
35
|
+
export type PinAttemptInsert = typeof posPinAttempts.$inferInsert;
|
|
36
|
+
|
|
33
37
|
export type TransactionStatus = "open" | "held" | "completed" | "voided";
|
|
34
38
|
export type TransactionType = "sale" | "return" | "exchange";
|
|
35
39
|
export type PaymentMethod = "cash" | "card" | "gift_card" | "store_credit" | "other";
|
|
@@ -50,6 +54,10 @@ export interface POSPluginOptions {
|
|
|
50
54
|
apiKeyScope?: string;
|
|
51
55
|
/** Shift-credential lifetime in seconds. Default: 43200 (12h). */
|
|
52
56
|
credentialTtlSeconds?: number;
|
|
57
|
+
/** Failed PIN attempts before lockout. Default: 5 (SEC-15). */
|
|
58
|
+
lockoutMaxAttempts?: number;
|
|
59
|
+
/** Failure counting window and lockout duration in minutes. Default: 15 (SEC-15). */
|
|
60
|
+
lockoutWindowMinutes?: number;
|
|
53
61
|
};
|
|
54
62
|
}
|
|
55
63
|
|