Phase 1 — LIFF Wrap (1-2 days parallel, ~9 CC sessions)
Goal: Pasukuru FE runs inside LINE LIFF. User auto-identified by line_user_id. End-to-end browse → cart → checkout works inside LINE WebView.
Exit gate: Tap LINE rich menu → LIFF opens → user sees Pasukuru shop → adds to cart → completes Stripe Checkout → returns to LIFF → sees order confirmation. Same human in LineFollower table = same Pasukuru member.
Decisions in this phase
1.D-1 — Multi-tenant LIFF routing strategy ⚠️ BLOCKER
-
Status: open — must resolve before 1.F-4 / 1.F-10
-
Owner: Ryo + Julian + r_goto
-
Context: Pasukuru BE today resolves tenant from
Hostheader viaDomainAdminMiddleware. Inside LIFF, all users hit the same LIFF URL (e.g.https://liff.line.me/{liffId}), which redirects to a single endpoint URL. So Host won’t differentiate tenants. -
Options:
-
A — Per-tenant LIFF URL (subdomain)
- Each Pasukuru tenant gets unique LIFF app + endpoint URL
https://shop1.passkuru.com/liff/shop - Pros: zero code change, Host header keeps working
- Cons: must register LIFF app per tenant in LINE Console (manual, scales poorly)
- Each Pasukuru tenant gets unique LIFF app + endpoint URL
-
B — Single LIFF, JWT-encoded
shop_idfrom Curva- One LIFF app per FUNCTION (shop, cart, account). Curva injects signed JWT param
?token=...containingshop_idwhen generating LIFF URL - Pros: single LIFF registration per function; scales to N tenants without LINE Console changes
- Cons: Pasukuru FE must parse + verify JWT → needs new middleware on Next.js side
- One LIFF app per FUNCTION (shop, cart, account). Curva injects signed JWT param
-
C — Path-based:
https://shop.passkuru.com/{shopSlug}/...- One LIFF app, endpoint URL has
{shopSlug}placeholder. Pasukuru FE reads slug from path, sends as header to BE. - Pros: no JWT crypto. Bookmarkable URLs.
- Cons: shopSlug exposed (privacy?), all tenants share LIFF channel (LINE login consent shared)
- One LIFF app, endpoint URL has
-
-
Recommendation: Option B (JWT) for production scale. Combine with Option C path for human-readable URLs. Curva generates signed
?token=<jwt>→ Pasukuru FE verifies + extracts shop_id, falls back to path slug if no token. -
Reversibility: Hard — affects URL structure visible to LINE users. Decide once.
1.D-2 — LIFF idToken verification: Curva-side or LINE-direct?
- Status: open
- Options:
- Pasukuru BE → calls
https://api.line.me/oauth2/v2.1/verifydirectly with idToken - Pasukuru BE → calls Curva
/integration/verify-liff-token(Curva calls LINE)
- Pasukuru BE → calls
- Recommendation: Option 1 — fewer hops, simpler debug. Curva’s API key auth still required for the broader bridge, but token verify is a LINE-public endpoint.
- Blocks: 1.P-2
1.D-3 — Cookie strategy for LIFF WebView
- Status: open
- Context: LIFF runs in iframe-like WebView. Auth cookies must work. iOS Safari WebView strict on third-party cookies.
- Options:
- Cookies
SameSite=None; Secure; HttpOnly - Token in
localStorage(no cookies) - Authorization header from JS (token returned on identify, stored in memory)
- Cookies
- Recommendation: Option 3 — most resilient to WebView quirks. Slight UX cost (lose session on tab close, but LIFF re-init re-issues).
- Blocks: 1.F-5, 1.F-11
Tracking matrix
| ID | Title | Track | Jira | CC | Risk | Status | Depends |
|---|---|---|---|---|---|---|---|
| 1.P-2 | POST /integration/line/identify | P | PASS-? | 1 | M | Backlog | 0.P-1, 1.D-2 |
| 1.P-9 | Multi-tenant LIFF resolution | P | PASS-? | 1.5 | H | Backlog | 1.D-1 |
| 1.F-1 | Install @line/liff | F | PASS-? | XS | L | Backlog | — |
| 1.F-2 | LiffProvider component | F | PASS-? | 0.5 | L | Backlog | 1.F-1 |
| 1.F-3 | useLiff() hook | F | PASS-? | 0.5 | L | Backlog | 1.F-2 |
| 1.F-4 | LIFF init on app boot | F | PASS-? | 0.5 | M | Backlog | 1.F-3, 1.D-1 |
| 1.F-5 | Auto-identify flow → JWT | F | PASS-? | 1 | M | Backlog | 1.F-4, 1.P-2, 1.D-3 |
| 1.F-6 | LIFF-aware cart route | F | PASS-? | 1 | L | Backlog | 1.F-5 |
| 1.F-7 | LIFF checkout + Stripe openWindow | F | PASS-? | 1 | M | Backlog | 1.F-6 |
| 1.F-10 | Tenant resolution from LIFF | F | PASS-? | 1 | M | Backlog | 1.F-4, 1.D-1 |
| 1.F-11 | Cookie/token strategy implementation | F | PASS-? | 0.5 | M | Backlog | 1.D-3 |
| 1.C-12 | LIFF app registration helper (Curva) | C | CRV-? | 1 | L | Backlog | 1.D-1 |
Total: 9.6 CC sessions = ~1.5-2 days parallel.
Items detail (selected critical ones)
1.P-2 — POST /api/v1/integration/line/identify
Track: Pasukuru BE CC: M (1) Depends: 0.P-1, 1.D-2 Risk: M Goal: Endpoint accepts LIFF idToken, verifies with LINE, upserts member, returns app JWT.
Files:
src/app/integration/integration.controller.ts(edit)src/app/integration/services/line-id-token.service.ts(new)src/app/integration/services/integration.service.ts(edit — addidentifyByLineIdToken)src/app/integration/dto/identify-line.dto.ts(new)src/app/auth/services/jwt.service.ts(edit — add member token issue)
Endpoint: POST /api/v1/integration/line/identify
Auth: none (public — token verifies caller)
Body: { idToken: string, channelId: string }
Returns: { token: string (JWT), member: {id, displayName, email, lineUserId} }
Verify flow:
- POST
https://api.line.me/oauth2/v2.1/verifywithid_token+client_id=channelId - Validate
iss == "https://access.line.me",aud == channelId,exp > now - Extract
sub(line_user_id),name,email?,picture? - Resolve tenant: from
Hostheader OR JWT shop_id from query (per 1.D-1) - Upsert member by
(adminId, sub)→ existing service from 0.P-3 reused - Issue Pasukuru member JWT (existing
member-jwt.strategy)
Acceptance:
- Valid idToken → 200 with JWT
- Invalid sig / expired / wrong aud → 401
- Idempotent: same idToken twice = same memberId
- Rate limited (10/min per IP)
- e2e test with mock LINE verify response
1.P-9 — Multi-tenant LIFF resolution
Track: Pasukuru BE CC: L (1.5) Depends: 1.D-1 Risk: H (touches every request path) Goal: Resolve tenant from JWT shop_id, fall back to Host header.
Files:
src/app/master/domain/middleware/domain-admin.middleware.ts(edit)src/app/master/domain/middleware/tenant-from-line.middleware.ts(new)
Logic:
- New middleware
TenantFromLineMiddlewareruns BEFOREDomainAdminMiddleware - Reads
Authorization: Bearer <jwt>OR?shop_token=<jwt>from query - If JWT present + valid + has
shop_idclaim → setreq.adminIddirectly, skip Host lookup - Else → fall through to existing Host-based resolution
Acceptance:
- LIFF request with shop_token → resolves to correct adminId
- Web request without token → resolves via Host (no regression)
- Invalid token → 401 (don’t fall through silently)
- e2e test for both paths
- Existing admin / member endpoints unaffected
Rollout:
- Feature flag:
tenant.line_resolution - Canary tenant first (1 staging tenant), monitor 24h, then enable globally
1.F-1 — Install LIFF SDK
Track: Pasukuru FE
CC: XS (0.1)
Goal: Add @line/liff to dependencies.
Steps:
npm install @line/liff- Add
NEXT_PUBLIC_LIFF_ID_SHOP,NEXT_PUBLIC_LIFF_ID_CART,NEXT_PUBLIC_LIFF_ID_ACCOUNTtoenv.mjsschema - Update
.env.example
1.F-2 + 1.F-3 — LiffProvider + useLiff()
Track: Pasukuru FE
CC: S each
Goal: React context exposing { liff, profile, idToken, isInClient, isReady }.
Files:
src/providers/LiffProvider.tsx(new)src/hooks/useLiff.ts(new)
Provider logic:
- On mount → if env LIFF_ID present →
liff.init({ liffId }) - After init → if
liff.isLoggedIn()→liff.getProfile()+liff.getIDToken() - Else →
liff.login({ redirectUri }) - Expose state via context
1.F-5 — Auto-identify flow
Track: Pasukuru FE CC: M (1) Depends: 1.F-4, 1.P-2, 1.D-3 Goal: On LIFF ready → call identify endpoint → store JWT in memory + axios header.
Files:
src/services/auth/line-identify.service.ts(new)src/repositories/auth/index.ts(edit — interceptor)src/stores/auth.store.ts(edit or new — Zustand)
Steps:
- Hook
useLiffIdentify()— runs whenuseLiff().isReady && idToken - Calls
POST /api/v1/integration/line/identifywith{ idToken, channelId } - On 200 → store
tokenin Zustand - Axios interceptor injects
Authorization: Bearer <token>for all subsequent BE calls - On 401 → clear token, re-call identify
Acceptance:
- Identify happens once per LIFF session
- BE calls auto-authenticated
- Manual web flow (no LIFF) unaffected — falls back to existing email/password auth
- React Query cache cleared on identity change
1.F-7 — LIFF checkout + Stripe openWindow
Track: Pasukuru FE
CC: M (1)
Goal: Stripe Checkout opens in external browser via liff.openWindow, returns to LIFF on success.
Files:
src/app/checkout/page.tsx(edit)src/services/order/checkout.service.ts(edit)
Steps:
- On checkout submit → BE creates Stripe Checkout Session
- If
useLiff().isInClient→liff.openWindow({ url: checkoutUrl, external: true }) - Else →
window.location = checkoutUrl - Stripe redirects success →
https://shop.passkuru.com/checkout/success?session={ID}→ which is also a LIFF endpoint → re-init → show confirmation - Stripe webhook (existing) updates order status → triggers Phase 0 order.paid event → LINE chat receives Flex Msg
Acceptance:
- Checkout works in LIFF + web
- Success returns user inside LIFF
- Order status correctly updated via existing Stripe webhook
- Receipt Flex Msg arrives in LINE chat (manual test confirms Phase 0 wiring)
1.C-12 — LIFF app registration helper (Curva)
Track: Curva CC: M (1) Depends: 1.D-1 Goal: Admin UI button “Auto-register Pasukuru LIFF apps” creates 3 LineLiff rows per LineAccount with the correct LIFF IDs.
Files:
app/Actions/Member/LineAccount/Liff/AutoRegisterPasukuruLiffsAction.php(new)app/Http/Controllers/Member/LineAccount/LineLiffController.php(edit — addautoRegisterPasukurumethod)routes/web-member.php(edit)resources/js/Pages/Member/LineAccounts/Liffs/Index.vue(edit — add button)
Logic:
- Reads PartnerConnection for current LineAccount → gets
partner_shop_id - Generates 3 LIFF URLs (with shop_token JWT signed using shared secret):
shop:https://liff.line.me/{LIFF_SHOP}?shop_token=<jwt>cart:https://liff.line.me/{LIFF_CART}?shop_token=<jwt>account:https://liff.line.me/{LIFF_ACCOUNT}?shop_token=<jwt>
- Inserts 3 LineLiff rows with
usage_type='pasukuru_shop|pasukuru_cart|pasukuru_account'
Acceptance:
- Button creates 3 rows
- Idempotent (clicking twice doesn’t duplicate)
- JWT verifiable by Pasukuru BE (shared secret)
- Pest test
Phase 1 smoke test
Setup:
- Phase 0 complete + green
- LIFF apps created in LINE Developers Console (3 apps: shop, cart, account)
- Pasukuru staging deployed with feature flags ON
- Curva staging has 1.C-12 registered LIFF rows
Procedure:
- User adds friend to Curva-managed LINE OA
- User taps Rich Menu “Open Shop” (URL action → LIFF shop URL)
- LINE opens LIFF → Pasukuru FE loads
- LIFF SDK initializes → idToken obtained
- Auto-identify call → Pasukuru BE creates Member with line_user_id
- User browses products (loaded from BE)
- Add to cart → cart page → checkout
- Stripe Checkout opens external → user pays test card → returns to LIFF
- Order created → Pasukuru emits
order.created(Phase 0 wiring) - Curva receives → currently Phase 0 stub Action just logs (Phase 2 will push Flex Msg)
Pass: all 10 steps green.
Risk register (Phase 1)
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| LIFF SDK init fails on iOS Safari | M | H | test matrix: LINE iOS, LINE Android, LIFF browser, regular Safari |
| JWT secret leak between Curva ↔ Pasukuru | L | H | rotate secret quarterly; monitor logs for invalid tokens |
| Stripe redirect breaks return path | M | M | use liff.openWindow({external:true}) not iframe |
| LINE login consent screen confuses users | L | M | minimize scopes (profile openid email) |
| Multi-tenant resolution fails for some tenants | M | H | canary tenant first; rollback flag exists |
localStorage/cookie quirks in WebView | M | M | use Zustand in-memory store (per 1.D-3) |
Phase 1 exit checklist
- All items DoD
- All 3 LIFF apps registered in LINE Console (manual, 3x)
- Smoke test passed (LINE iOS + Android both)
- No regression in web (non-LIFF) shop flow
- Vault updated
- User sign-off → Phase 2